feat(cli): add @tpmjs/cli package and browser auth page
- Create comprehensive CLI with oclif framework - Add commands: auth, tool, agent, collection, mcp, publish - Add doctor, playground, and update commands - Add /cli/auth page for browser-based OAuth flow - Publish to npm as @tpmjs/cli@0.1.2
This commit is contained in:
parent
aa438078f9
commit
154f000505
41 changed files with 7046 additions and 0 deletions
246
apps/web/src/app/cli/auth/page.tsx
Normal file
246
apps/web/src/app/cli/auth/page.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useCallback, useState } from 'react';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
function CliAuthContent(): React.ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
const searchParams = useSearchParams();
|
||||
const [isAuthorizing, setIsAuthorizing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const state = searchParams.get('state');
|
||||
const callback = searchParams.get('callback');
|
||||
|
||||
const handleAuthorize = useCallback(async () => {
|
||||
if (!state || !callback) {
|
||||
setError('Missing state or callback parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthorizing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create an API key for CLI access
|
||||
const response = await fetch('/api/user/tpmjs-api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: `CLI (${new Date().toLocaleDateString()})` }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to create API key');
|
||||
setIsAuthorizing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect back to CLI with the API key
|
||||
const callbackUrl = new URL(callback);
|
||||
callbackUrl.searchParams.set('state', state);
|
||||
callbackUrl.searchParams.set('key', result.apiKey.key);
|
||||
|
||||
window.location.href = callbackUrl.toString();
|
||||
} catch (err) {
|
||||
console.error('Authorization failed:', err);
|
||||
setError('Failed to authorize. Please try again.');
|
||||
setIsAuthorizing(false);
|
||||
}
|
||||
}, [state, callback]);
|
||||
|
||||
const handleDeny = useCallback(() => {
|
||||
if (!callback || !state) return;
|
||||
|
||||
const callbackUrl = new URL(callback);
|
||||
callbackUrl.searchParams.set('state', state);
|
||||
callbackUrl.searchParams.set('error', 'access_denied');
|
||||
|
||||
window.location.href = callbackUrl.toString();
|
||||
}, [state, callback]);
|
||||
|
||||
// Validate parameters
|
||||
if (!state || !callback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="max-w-md w-full bg-surface border border-border rounded-xl p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-error/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Icon icon="alertCircle" size="lg" className="text-error" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground mb-2">Invalid Request</h1>
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
Missing required parameters. Please try authenticating again from the CLI.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => window.close()}>
|
||||
Close Window
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Icon icon="loader" size="lg" className="text-primary animate-spin" />
|
||||
<p className="text-foreground-secondary">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not signed in
|
||||
if (!session?.user) {
|
||||
const returnUrl = `/cli/auth?state=${encodeURIComponent(state)}&callback=${encodeURIComponent(callback)}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="max-w-md w-full bg-surface border border-border rounded-xl p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Icon icon="terminal" size="lg" className="text-primary" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground mb-2">CLI Authentication</h1>
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
Sign in to authorize the TPMJS CLI to access your account.
|
||||
</p>
|
||||
<Link href={`/sign-in?redirect=${encodeURIComponent(returnUrl)}`}>
|
||||
<Button className="w-full">
|
||||
<Icon icon="user" size="sm" className="mr-2" />
|
||||
Sign In to Continue
|
||||
</Button>
|
||||
</Link>
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
Don't have an account?{' '}
|
||||
<Link href={`/sign-up?redirect=${encodeURIComponent(returnUrl)}`} className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
|
||||
// Signed in - show authorization prompt
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="max-w-md w-full bg-surface border border-border rounded-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Icon icon="terminal" size="lg" className="text-primary" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground mb-2">Authorize CLI Access</h1>
|
||||
<p className="text-foreground-secondary">
|
||||
The TPMJS CLI is requesting access to your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="bg-surface-secondary border border-border rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{user.image ? (
|
||||
<img
|
||||
src={user.image}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="user" size="sm" className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{user.name || 'User'}
|
||||
</p>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">This will allow the CLI to:</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||
<Icon icon="check" size="xs" className="text-success" />
|
||||
Access your collections and agents
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||
<Icon icon="check" size="xs" className="text-success" />
|
||||
Execute tools on your behalf
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||
<Icon icon="check" size="xs" className="text-success" />
|
||||
Manage your TPMJS resources
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-error/10 border border-error/30 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-error">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
onClick={handleAuthorize}
|
||||
disabled={isAuthorizing}
|
||||
className="w-full"
|
||||
>
|
||||
{isAuthorizing ? (
|
||||
<>
|
||||
<Icon icon="loader" size="sm" className="mr-2 animate-spin" />
|
||||
Authorizing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="check" size="sm" className="mr-2" />
|
||||
Authorize CLI
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDeny}
|
||||
disabled={isAuthorizing}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground-tertiary text-center mt-6">
|
||||
An API key will be created and sent to the CLI.
|
||||
You can revoke it anytime from your dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CliAuthPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Icon icon="loader" size="lg" className="text-primary animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CliAuthContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
127
packages/cli/README.md
Normal file
127
packages/cli/README.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# @tpmjs/cli
|
||||
|
||||
Command-line interface for TPMJS - the universal tool registry for AI agents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g @tpmjs/cli
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Search for tools
|
||||
tpm tool search firecrawl
|
||||
|
||||
# Get tool info
|
||||
tpm tool info @tpmjs/official-firecrawl scrapeTool
|
||||
|
||||
# Show trending tools
|
||||
tpm tool trending
|
||||
|
||||
# Authenticate
|
||||
tpm auth login --api-key YOUR_API_KEY
|
||||
|
||||
# List your agents
|
||||
tpm agent list
|
||||
|
||||
# List your collections
|
||||
tpm collection list
|
||||
|
||||
# Generate MCP config
|
||||
tpm mcp config ajax/ajax-collection
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
tpm auth login # Login with API key or browser OAuth
|
||||
tpm auth logout # Log out
|
||||
tpm auth status # Show authentication status
|
||||
tpm auth whoami # Show current user info
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
```bash
|
||||
tpm tool search [query] # Search for tools
|
||||
tpm tool info <pkg> <tool> # Get tool details
|
||||
tpm tool trending # Show trending tools
|
||||
tpm tool validate # Validate local tpmjs config
|
||||
```
|
||||
|
||||
### Agents
|
||||
|
||||
```bash
|
||||
tpm agent list # List your agents
|
||||
tpm agent create # Create a new agent
|
||||
tpm agent update <id> # Update an agent
|
||||
tpm agent delete <id> # Delete an agent
|
||||
tpm agent chat <id> # Chat with an agent
|
||||
```
|
||||
|
||||
### Collections
|
||||
|
||||
```bash
|
||||
tpm collection list # List your collections
|
||||
tpm collection create # Create a collection
|
||||
tpm collection add # Add tools to a collection
|
||||
tpm collection remove # Remove tools from a collection
|
||||
```
|
||||
|
||||
### MCP Integration
|
||||
|
||||
```bash
|
||||
tpm mcp config <user/collection> # Generate MCP config
|
||||
tpm mcp serve # Run as local MCP server
|
||||
```
|
||||
|
||||
### Utilities
|
||||
|
||||
```bash
|
||||
tpm doctor # Run diagnostic checks
|
||||
tpm update # Update CLI to latest version
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is stored in `~/.tpmjs/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiUrl": "https://tpmjs.com/api",
|
||||
"defaultOutput": "human",
|
||||
"verbose": false,
|
||||
"analytics": false
|
||||
}
|
||||
```
|
||||
|
||||
Credentials are stored securely in `~/.tpmjs/credentials.json`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `TPMJS_API_KEY` - API key for authentication
|
||||
- `TPMJS_API_URL` - Custom API URL (default: https://tpmjs.com/api)
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support `--json` flag for machine-readable output:
|
||||
|
||||
```bash
|
||||
tpm tool search firecrawl --json | jq '.data[0].name'
|
||||
```
|
||||
|
||||
## Verbose Mode
|
||||
|
||||
Use `--verbose` or `-v` for detailed output:
|
||||
|
||||
```bash
|
||||
tpm doctor --verbose
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
5
packages/cli/bin/run.js
Normal file
5
packages/cli/bin/run.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execute } from '@oclif/core';
|
||||
|
||||
await execute({ dir: import.meta.url });
|
||||
1699
packages/cli/oclif.manifest.json
Normal file
1699
packages/cli/oclif.manifest.json
Normal file
File diff suppressed because it is too large
Load diff
103
packages/cli/package.json
Normal file
103
packages/cli/package.json
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"name": "@tpmjs/cli",
|
||||
"version": "0.1.2",
|
||||
"description": "TPMJS command-line interface for AI tool discovery and execution",
|
||||
"author": "TPMJS",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tpmjs/tpmjs.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"homepage": "https://tpmjs.com",
|
||||
"keywords": [
|
||||
"tpmjs",
|
||||
"cli",
|
||||
"mcp",
|
||||
"ai",
|
||||
"tools",
|
||||
"agents"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"tpm": "./bin/run.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"oclif.manifest.json"
|
||||
],
|
||||
"oclif": {
|
||||
"bin": "tpm",
|
||||
"dirname": "tpmjs",
|
||||
"commands": "./dist/commands",
|
||||
"plugins": [
|
||||
"@oclif/plugin-help",
|
||||
"@oclif/plugin-plugins",
|
||||
"@oclif/plugin-autocomplete"
|
||||
],
|
||||
"hooks": {
|
||||
"init": "./dist/hooks/init"
|
||||
},
|
||||
"topics": {
|
||||
"tool": {
|
||||
"description": "Tool discovery and execution"
|
||||
},
|
||||
"agent": {
|
||||
"description": "AI agent management"
|
||||
},
|
||||
"collection": {
|
||||
"description": "Collection management"
|
||||
},
|
||||
"auth": {
|
||||
"description": "Authentication"
|
||||
},
|
||||
"mcp": {
|
||||
"description": "MCP integration"
|
||||
},
|
||||
"publish": {
|
||||
"description": "Publishing workflow"
|
||||
}
|
||||
},
|
||||
"topicSeparator": " "
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup && oclif manifest",
|
||||
"dev": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist .turbo oclif.manifest.json",
|
||||
"postpack": "rm -f oclif.manifest.json",
|
||||
"prepack": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oclif/core": "^4.2.10",
|
||||
"@oclif/plugin-help": "^6.2.27",
|
||||
"@oclif/plugin-plugins": "^5.4.36",
|
||||
"@oclif/plugin-autocomplete": "^3.2.25",
|
||||
"conf": "^13.1.0",
|
||||
"open": "^10.1.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"ora": "^8.2.0",
|
||||
"eventsource-parser": "^3.0.1",
|
||||
"cli-table3": "^0.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tpmjs/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.29",
|
||||
"oclif": "^4.17.35",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
216
packages/cli/src/commands/agent/chat.ts
Normal file
216
packages/cli/src/commands/agent/chat.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import * as readline from 'node:readline';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
import { getApiKey, getApiUrl } from '../../lib/config.js';
|
||||
|
||||
export default class AgentChat extends Command {
|
||||
static description = 'Chat with an agent';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-agent "Hello!"',
|
||||
'<%= config.bin %> <%= command.id %> my-agent --interactive',
|
||||
'<%= config.bin %> <%= command.id %> my-agent -i',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
interactive: Flags.boolean({
|
||||
char: 'i',
|
||||
description: 'Enter interactive chat mode (REPL)',
|
||||
default: false,
|
||||
}),
|
||||
conversation: Flags.string({
|
||||
char: 'c',
|
||||
description: 'Continue existing conversation by ID',
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
agent: Args.string({
|
||||
description: 'Agent ID or UID',
|
||||
required: true,
|
||||
}),
|
||||
message: Args.string({
|
||||
description: 'Message to send (required unless --interactive)',
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(AgentChat);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify agent exists
|
||||
const agentResponse = await client.getAgent(args.agent);
|
||||
if (!agentResponse.success || !agentResponse.data) {
|
||||
output.error('Agent not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = agentResponse.data;
|
||||
|
||||
if (flags.interactive) {
|
||||
await this.interactiveChat(agent, flags, output);
|
||||
} else if (args.message) {
|
||||
await this.singleMessage(agent, args.message, flags, output);
|
||||
} else {
|
||||
output.error('Please provide a message or use --interactive flag');
|
||||
output.text('Examples:');
|
||||
output.listItem(`tpm agent chat ${args.agent} "Hello!"`);
|
||||
output.listItem(`tpm agent chat ${args.agent} --interactive`);
|
||||
}
|
||||
}
|
||||
|
||||
private async singleMessage(
|
||||
agent: { id: string; name: string },
|
||||
message: string,
|
||||
flags: { json?: boolean; verbose?: boolean; conversation?: string },
|
||||
output: ReturnType<typeof createOutput>
|
||||
): Promise<void> {
|
||||
const spinner = output.spinner('Sending message...');
|
||||
|
||||
try {
|
||||
const response = await this.sendChatMessage(agent.id, message, flags.conversation);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.content) {
|
||||
output.text(response.content);
|
||||
}
|
||||
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
output.newLine();
|
||||
output.subheading('Tool Calls:');
|
||||
for (const call of response.toolCalls) {
|
||||
output.listItem(`${call.name}: ${JSON.stringify(call.result)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to send message');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async interactiveChat(
|
||||
agent: { id: string; name: string; uid: string },
|
||||
flags: { json?: boolean; verbose?: boolean; conversation?: string },
|
||||
output: ReturnType<typeof createOutput>
|
||||
): Promise<void> {
|
||||
output.heading(`Chat with ${agent.name}`);
|
||||
output.text(output.dim('Type "exit" or Ctrl+C to quit'));
|
||||
output.hr();
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const conversationId = flags.conversation;
|
||||
|
||||
const prompt = () => {
|
||||
rl.question('\n> ', async (message) => {
|
||||
if (message.toLowerCase() === 'exit' || message.toLowerCase() === 'quit') {
|
||||
output.info('Goodbye!');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim()) {
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.stdout.write('\n');
|
||||
const response = await this.sendChatMessage(agent.id, message, conversationId);
|
||||
|
||||
if (response.content) {
|
||||
output.text(response.content);
|
||||
}
|
||||
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
output.newLine();
|
||||
output.text(output.dim('Tool calls:'));
|
||||
for (const call of response.toolCalls) {
|
||||
output.text(output.dim(` • ${call.name}`));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
output.error(error instanceof Error ? error.message : 'Failed to send message');
|
||||
}
|
||||
|
||||
prompt();
|
||||
});
|
||||
};
|
||||
|
||||
prompt();
|
||||
|
||||
// Handle Ctrl+C
|
||||
rl.on('SIGINT', () => {
|
||||
output.newLine();
|
||||
output.info('Goodbye!');
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendChatMessage(
|
||||
agentId: string,
|
||||
message: string,
|
||||
conversationId?: string
|
||||
): Promise<{
|
||||
content: string;
|
||||
conversationId: string;
|
||||
toolCalls?: { name: string; result: unknown }[];
|
||||
}> {
|
||||
const apiKey = getApiKey();
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const url = `${apiUrl}/agents/${agentId}/chat`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
conversationId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})) as { message?: string };
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
content: string;
|
||||
conversationId: string;
|
||||
toolCalls?: { name: string; result: unknown }[];
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}
|
||||
114
packages/cli/src/commands/agent/create.ts
Normal file
114
packages/cli/src/commands/agent/create.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class AgentCreate extends Command {
|
||||
static description = 'Create a new agent';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> --name "My Agent" --provider ANTHROPIC --model claude-3-5-sonnet-20241022',
|
||||
'<%= config.bin %> <%= command.id %> --name "GPT Agent" --provider OPENAI --model gpt-4o --public',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
name: Flags.string({
|
||||
char: 'n',
|
||||
description: 'Agent name',
|
||||
required: true,
|
||||
}),
|
||||
uid: Flags.string({
|
||||
description: 'Unique identifier (URL-friendly)',
|
||||
}),
|
||||
description: Flags.string({
|
||||
char: 'd',
|
||||
description: 'Agent description',
|
||||
}),
|
||||
provider: Flags.string({
|
||||
char: 'p',
|
||||
description: 'AI provider (ANTHROPIC, OPENAI, GOOGLE, GROQ, MISTRAL)',
|
||||
required: true,
|
||||
options: ['ANTHROPIC', 'OPENAI', 'GOOGLE', 'GROQ', 'MISTRAL'],
|
||||
}),
|
||||
model: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Model ID',
|
||||
required: true,
|
||||
}),
|
||||
'system-prompt': Flags.string({
|
||||
char: 's',
|
||||
description: 'System prompt',
|
||||
}),
|
||||
temperature: Flags.string({
|
||||
char: 't',
|
||||
description: 'Temperature (0-2)',
|
||||
default: '0.7',
|
||||
}),
|
||||
public: Flags.boolean({
|
||||
description: 'Make agent public',
|
||||
default: true,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(AgentCreate);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Creating agent...');
|
||||
|
||||
try {
|
||||
const response = await client.createAgent({
|
||||
name: flags.name,
|
||||
uid: flags.uid,
|
||||
description: flags.description,
|
||||
provider: flags.provider as 'ANTHROPIC' | 'OPENAI' | 'GOOGLE' | 'GROQ' | 'MISTRAL',
|
||||
modelId: flags.model,
|
||||
systemPrompt: flags['system-prompt'],
|
||||
temperature: parseFloat(flags.temperature),
|
||||
isPublic: flags.public,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
output.error(response.message || 'Failed to create agent');
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Agent "${response.data.name}" created successfully`);
|
||||
output.newLine();
|
||||
output.keyValue('ID', response.data.id);
|
||||
output.keyValue('UID', response.data.uid);
|
||||
output.keyValue('Provider', response.data.provider);
|
||||
output.keyValue('Model', response.data.modelId);
|
||||
output.keyValue('Public', response.data.isPublic ? 'Yes' : 'No');
|
||||
output.newLine();
|
||||
output.text(`Chat with it: tpm agent chat ${response.data.uid}`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create agent');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
packages/cli/src/commands/agent/delete.ts
Normal file
103
packages/cli/src/commands/agent/delete.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
import * as readline from 'node:readline';
|
||||
|
||||
export default class AgentDelete extends Command {
|
||||
static description = 'Delete an agent';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-agent',
|
||||
'<%= config.bin %> <%= command.id %> my-agent --force',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
force: Flags.boolean({
|
||||
char: 'f',
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
id: Args.string({
|
||||
description: 'Agent ID or UID',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(AgentDelete);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get agent info first
|
||||
const agentResponse = await client.getAgent(args.id);
|
||||
if (!agentResponse.success || !agentResponse.data) {
|
||||
output.error('Agent not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = agentResponse.data;
|
||||
|
||||
// Confirm deletion
|
||||
if (!flags.force) {
|
||||
const confirmed = await this.confirm(
|
||||
`Are you sure you want to delete agent "${agent.name}"? This cannot be undone.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
output.info('Deletion cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Deleting agent...');
|
||||
|
||||
try {
|
||||
await client.deleteAgent(args.id);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({ success: true, deleted: args.id });
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Agent "${agent.name}" deleted successfully`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to delete agent');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async confirm(message: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${message} [y/N] `, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
102
packages/cli/src/commands/agent/list.ts
Normal file
102
packages/cli/src/commands/agent/list.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class AgentList extends Command {
|
||||
static description = 'List your agents';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --limit 10',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
limit: Flags.integer({
|
||||
char: 'l',
|
||||
description: 'Maximum number of results',
|
||||
default: 20,
|
||||
}),
|
||||
offset: Flags.integer({
|
||||
char: 'o',
|
||||
description: 'Offset for pagination',
|
||||
default: 0,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(AgentList);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Fetching agents...');
|
||||
|
||||
try {
|
||||
const response = await client.listAgents({
|
||||
limit: flags.limit,
|
||||
offset: flags.offset,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.length === 0) {
|
||||
output.info('No agents found');
|
||||
output.text('Create one with: tpm agent create');
|
||||
return;
|
||||
}
|
||||
|
||||
output.table(
|
||||
response.data.map((agent) => ({
|
||||
uid: agent.uid,
|
||||
name: agent.name,
|
||||
provider: agent.provider,
|
||||
model: agent.modelId,
|
||||
public: agent.isPublic ? 'Yes' : 'No',
|
||||
tools: agent._count?.tools ?? 0,
|
||||
collections: agent._count?.collections ?? 0,
|
||||
})),
|
||||
[
|
||||
{ key: 'uid', header: 'UID', width: 20 },
|
||||
{ key: 'name', header: 'Name', width: 25 },
|
||||
{ key: 'provider', header: 'Provider', width: 12 },
|
||||
{ key: 'model', header: 'Model', width: 20 },
|
||||
{ key: 'public', header: 'Public', width: 8 },
|
||||
{ key: 'tools', header: 'Tools', width: 7 },
|
||||
{ key: 'collections', header: 'Collections', width: 12 },
|
||||
]
|
||||
);
|
||||
|
||||
output.newLine();
|
||||
output.text(
|
||||
output.dim(
|
||||
`Showing ${response.data.length} agent(s)` +
|
||||
(response.pagination.hasMore ? ` (more available)` : '')
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch agents');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/cli/src/commands/agent/update.ts
Normal file
116
packages/cli/src/commands/agent/update.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class AgentUpdate extends Command {
|
||||
static description = 'Update an agent';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-agent --name "New Name"',
|
||||
'<%= config.bin %> <%= command.id %> my-agent --temperature 0.5 --public false',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
name: Flags.string({
|
||||
char: 'n',
|
||||
description: 'Agent name',
|
||||
}),
|
||||
uid: Flags.string({
|
||||
description: 'Unique identifier (URL-friendly)',
|
||||
}),
|
||||
description: Flags.string({
|
||||
char: 'd',
|
||||
description: 'Agent description',
|
||||
}),
|
||||
provider: Flags.string({
|
||||
char: 'p',
|
||||
description: 'AI provider',
|
||||
options: ['ANTHROPIC', 'OPENAI', 'GOOGLE', 'GROQ', 'MISTRAL'],
|
||||
}),
|
||||
model: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Model ID',
|
||||
}),
|
||||
'system-prompt': Flags.string({
|
||||
char: 's',
|
||||
description: 'System prompt',
|
||||
}),
|
||||
temperature: Flags.string({
|
||||
char: 't',
|
||||
description: 'Temperature (0-2)',
|
||||
}),
|
||||
public: Flags.boolean({
|
||||
description: 'Make agent public',
|
||||
allowNo: true,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
id: Args.string({
|
||||
description: 'Agent ID or UID',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(AgentUpdate);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build update payload
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (flags.name) updates.name = flags.name;
|
||||
if (flags.uid) updates.uid = flags.uid;
|
||||
if (flags.description !== undefined) updates.description = flags.description;
|
||||
if (flags.provider) updates.provider = flags.provider;
|
||||
if (flags.model) updates.modelId = flags.model;
|
||||
if (flags['system-prompt'] !== undefined) updates.systemPrompt = flags['system-prompt'];
|
||||
if (flags.temperature) updates.temperature = parseFloat(flags.temperature);
|
||||
if (flags.public !== undefined) updates.isPublic = flags.public;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
output.error('No updates specified. Use --help to see available options.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Updating agent...');
|
||||
|
||||
try {
|
||||
const response = await client.updateAgent(args.id, updates);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
output.error(response.message || 'Failed to update agent');
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Agent "${response.data.name}" updated successfully`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to update agent');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
200
packages/cli/src/commands/auth/login.ts
Normal file
200
packages/cli/src/commands/auth/login.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import open from 'open';
|
||||
import { createServer } from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
import { saveCredentials, getApiUrl } from '../../lib/config.js';
|
||||
import { TpmClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class Login extends Command {
|
||||
static description = 'Authenticate with TPMJS';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> --api-key tpm_xxxxx',
|
||||
'<%= config.bin %> <%= command.id %> --browser',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
'api-key': Flags.string({
|
||||
char: 'k',
|
||||
description: 'API key (or set TPMJS_API_KEY environment variable)',
|
||||
}),
|
||||
browser: Flags.boolean({
|
||||
char: 'b',
|
||||
description: 'Open browser for OAuth authentication',
|
||||
default: false,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
key: Args.string({
|
||||
description: 'API key (alternative to --api-key flag)',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(Login);
|
||||
const output = createOutput(flags);
|
||||
|
||||
const apiKey = flags['api-key'] ?? args.key;
|
||||
|
||||
if (apiKey) {
|
||||
// Direct API key authentication
|
||||
await this.loginWithApiKey(apiKey, output, flags);
|
||||
} else if (flags.browser) {
|
||||
// Browser OAuth flow
|
||||
await this.loginWithBrowser(output, flags);
|
||||
} else {
|
||||
// Prompt for API key
|
||||
output.info('No API key provided. Use --api-key or --browser flag.');
|
||||
output.newLine();
|
||||
output.text('Options:');
|
||||
output.listItem('tpm auth login --api-key <your-api-key>');
|
||||
output.listItem('tpm auth login --browser (opens browser for OAuth)');
|
||||
output.newLine();
|
||||
output.text(`Get your API key at: ${output.link('tpmjs.com/dashboard/settings/tpmjs-api-keys', 'https://tpmjs.com/dashboard/settings/tpmjs-api-keys')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loginWithApiKey(
|
||||
apiKey: string,
|
||||
output: ReturnType<typeof createOutput>,
|
||||
flags: { json?: boolean; verbose?: boolean }
|
||||
): Promise<void> {
|
||||
const spinner = output.spinner('Validating API key...');
|
||||
|
||||
try {
|
||||
// Test the API key
|
||||
const client = new TpmClient({ apiKey });
|
||||
const response = await client.whoami();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
spinner.fail('Invalid API key');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
saveCredentials({ apiKey });
|
||||
spinner.succeed('Logged in successfully');
|
||||
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
success: true,
|
||||
user: response.data,
|
||||
});
|
||||
} else {
|
||||
output.newLine();
|
||||
output.keyValue('Email', response.data.email);
|
||||
if (response.data.username) {
|
||||
output.keyValue('Username', response.data.username);
|
||||
}
|
||||
if (response.data.name) {
|
||||
output.keyValue('Name', response.data.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Authentication failed');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loginWithBrowser(
|
||||
output: ReturnType<typeof createOutput>,
|
||||
flags: { json?: boolean; verbose?: boolean }
|
||||
): Promise<void> {
|
||||
const port = 9876;
|
||||
const callbackUrl = `http://localhost:${port}/callback`;
|
||||
const state = crypto.randomUUID();
|
||||
|
||||
output.info('Opening browser for authentication...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
const receivedState = url.searchParams.get('state');
|
||||
const apiKey = url.searchParams.get('key');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1>Authentication Failed</h1><p>You can close this window.</p></body></html>');
|
||||
server.close();
|
||||
output.error(`Authentication failed: ${error}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (receivedState !== state) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1>Invalid State</h1><p>Authentication failed due to invalid state.</p></body></html>');
|
||||
server.close();
|
||||
output.error('Authentication failed: Invalid state parameter');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
saveCredentials({ apiKey });
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1>Success!</h1><p>You are now logged in. You can close this window.</p></body></html>');
|
||||
server.close();
|
||||
|
||||
output.success('Logged in successfully via browser');
|
||||
|
||||
if (flags.json) {
|
||||
output.json({ success: true });
|
||||
}
|
||||
|
||||
resolve();
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1>Error</h1><p>No API key received.</p></body></html>');
|
||||
server.close();
|
||||
output.error('No API key received from authentication');
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, async () => {
|
||||
const authUrl = `${getApiUrl().replace('/api', '')}/cli/auth?state=${state}&callback=${encodeURIComponent(callbackUrl)}`;
|
||||
|
||||
output.debug(`Auth URL: ${authUrl}`);
|
||||
output.text('Waiting for authentication...');
|
||||
|
||||
try {
|
||||
await open(authUrl);
|
||||
} catch {
|
||||
output.warning('Could not open browser automatically.');
|
||||
output.text(`Please open this URL manually: ${authUrl}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
output.error('Authentication timed out');
|
||||
resolve();
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
38
packages/cli/src/commands/auth/logout.ts
Normal file
38
packages/cli/src/commands/auth/logout.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { deleteCredentials, hasCredentials } from '../../lib/config.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class Logout extends Command {
|
||||
static description = 'Log out from TPMJS';
|
||||
|
||||
static examples = ['<%= config.bin %> <%= command.id %>'];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Logout);
|
||||
const output = createOutput(flags);
|
||||
|
||||
if (!hasCredentials()) {
|
||||
if (flags.json) {
|
||||
output.json({ success: true, message: 'Not logged in' });
|
||||
} else {
|
||||
output.info('Not logged in');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deleteCredentials();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({ success: true, message: 'Logged out successfully' });
|
||||
} else {
|
||||
output.success('Logged out successfully');
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/cli/src/commands/auth/status.ts
Normal file
116
packages/cli/src/commands/auth/status.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { hasCredentials, getApiKey, getApiUrl } from '../../lib/config.js';
|
||||
import { TpmClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class Status extends Command {
|
||||
static description = 'Show authentication status';
|
||||
|
||||
static examples = ['<%= config.bin %> <%= command.id %>'];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Status);
|
||||
const output = createOutput(flags);
|
||||
|
||||
const apiKey = getApiKey();
|
||||
const apiUrl = getApiUrl();
|
||||
const hasStoredCredentials = hasCredentials();
|
||||
|
||||
// Determine auth source
|
||||
let authSource: 'env' | 'config' | 'none' = 'none';
|
||||
if (process.env.TPMJS_API_KEY) {
|
||||
authSource = 'env';
|
||||
} else if (hasStoredCredentials) {
|
||||
authSource = 'config';
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
authenticated: false,
|
||||
authSource: null,
|
||||
apiUrl,
|
||||
});
|
||||
} else {
|
||||
output.warning('Not authenticated');
|
||||
output.newLine();
|
||||
output.text('Run `tpm auth login` to authenticate.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the API key
|
||||
const spinner = output.spinner('Checking authentication...');
|
||||
|
||||
try {
|
||||
const client = new TpmClient({ apiKey });
|
||||
const response = await client.whoami();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
spinner.fail('API key is invalid');
|
||||
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
authenticated: false,
|
||||
authSource,
|
||||
apiUrl,
|
||||
error: 'Invalid API key',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.succeed('Authenticated');
|
||||
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
authenticated: true,
|
||||
authSource,
|
||||
apiUrl,
|
||||
user: response.data,
|
||||
keyPrefix: apiKey.substring(0, 12) + '...',
|
||||
});
|
||||
} else {
|
||||
output.newLine();
|
||||
output.keyValue('Email', response.data.email);
|
||||
if (response.data.username) {
|
||||
output.keyValue('Username', response.data.username);
|
||||
}
|
||||
if (response.data.name) {
|
||||
output.keyValue('Name', response.data.name);
|
||||
}
|
||||
output.keyValue('API URL', apiUrl);
|
||||
output.keyValue('Auth Source', authSource === 'env' ? 'Environment variable' : 'Config file');
|
||||
output.keyValue('Key Prefix', apiKey.substring(0, 12) + '...');
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to verify authentication');
|
||||
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
authenticated: false,
|
||||
authSource,
|
||||
apiUrl,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} else {
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
packages/cli/src/commands/auth/whoami.ts
Normal file
64
packages/cli/src/commands/auth/whoami.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class Whoami extends Command {
|
||||
static description = 'Show current user information';
|
||||
|
||||
static examples = ['<%= config.bin %> <%= command.id %>'];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Whoami);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Fetching user info...');
|
||||
|
||||
try {
|
||||
const response = await client.whoami();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
spinner.fail('Failed to fetch user info');
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response.data);
|
||||
} else {
|
||||
output.keyValue('Email', response.data.email);
|
||||
if (response.data.username) {
|
||||
output.keyValue('Username', response.data.username);
|
||||
}
|
||||
if (response.data.name) {
|
||||
output.keyValue('Name', response.data.name);
|
||||
}
|
||||
output.keyValue('User ID', response.data.id);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch user info');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
packages/cli/src/commands/collection/add.ts
Normal file
77
packages/cli/src/commands/collection/add.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class CollectionAdd extends Command {
|
||||
static description = 'Add tools to a collection';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-collection tool-id-1',
|
||||
'<%= config.bin %> <%= command.id %> my-collection tool-id-1 tool-id-2 tool-id-3',
|
||||
];
|
||||
|
||||
static strict = false; // Allow variable number of arguments
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
collection: Args.string({
|
||||
description: 'Collection ID or slug',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, argv, flags } = await this.parse(CollectionAdd);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tool IDs from remaining arguments
|
||||
const toolIds = argv.slice(1) as string[];
|
||||
|
||||
if (toolIds.length === 0) {
|
||||
output.error('Please specify at least one tool ID to add');
|
||||
output.text('Example: tpm collection add my-collection tool-id-1 tool-id-2');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner(`Adding ${toolIds.length} tool(s)...`);
|
||||
|
||||
try {
|
||||
await client.addToolsToCollection(args.collection, toolIds);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({ success: true, added: toolIds.length, toolIds });
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Added ${toolIds.length} tool(s) to collection`);
|
||||
for (const toolId of toolIds) {
|
||||
output.listItem(toolId);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to add tools');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
packages/cli/src/commands/collection/create.ts
Normal file
86
packages/cli/src/commands/collection/create.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class CollectionCreate extends Command {
|
||||
static description = 'Create a new collection';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> --name "My Tools"',
|
||||
'<%= config.bin %> <%= command.id %> --name "Web Scrapers" --description "Tools for web scraping" --public',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
name: Flags.string({
|
||||
char: 'n',
|
||||
description: 'Collection name',
|
||||
required: true,
|
||||
}),
|
||||
description: Flags.string({
|
||||
char: 'd',
|
||||
description: 'Collection description',
|
||||
}),
|
||||
public: Flags.boolean({
|
||||
description: 'Make collection public',
|
||||
default: false,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(CollectionCreate);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Creating collection...');
|
||||
|
||||
try {
|
||||
const response = await client.createCollection({
|
||||
name: flags.name,
|
||||
description: flags.description,
|
||||
isPublic: flags.public,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
output.error(response.message || 'Failed to create collection');
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Collection "${response.data.name}" created successfully`);
|
||||
output.newLine();
|
||||
output.keyValue('ID', response.data.id);
|
||||
if (response.data.slug) {
|
||||
output.keyValue('Slug', response.data.slug);
|
||||
}
|
||||
output.keyValue('Public', response.data.isPublic ? 'Yes' : 'No');
|
||||
output.newLine();
|
||||
output.text(`Add tools: tpm collection add ${response.data.id} <tool-id>`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create collection');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
packages/cli/src/commands/collection/delete.ts
Normal file
103
packages/cli/src/commands/collection/delete.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import * as readline from 'node:readline';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class CollectionDelete extends Command {
|
||||
static description = 'Delete a collection';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-collection',
|
||||
'<%= config.bin %> <%= command.id %> my-collection --force',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
force: Flags.boolean({
|
||||
char: 'f',
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
id: Args.string({
|
||||
description: 'Collection ID or slug',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(CollectionDelete);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get collection info first
|
||||
const collectionResponse = await client.getCollection(args.id);
|
||||
if (!collectionResponse.success || !collectionResponse.data) {
|
||||
output.error('Collection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = collectionResponse.data;
|
||||
|
||||
// Confirm deletion
|
||||
if (!flags.force) {
|
||||
const confirmed = await this.confirm(
|
||||
`Are you sure you want to delete collection "${collection.name}"? This cannot be undone.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
output.info('Deletion cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Deleting collection...');
|
||||
|
||||
try {
|
||||
await client.deleteCollection(args.id);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({ success: true, deleted: args.id });
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Collection "${collection.name}" deleted successfully`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to delete collection');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async confirm(message: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${message} [y/N] `, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
124
packages/cli/src/commands/collection/import.ts
Normal file
124
packages/cli/src/commands/collection/import.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class CollectionImport extends Command {
|
||||
static description = 'Import tools to a collection from a file';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-collection --file tools.txt',
|
||||
'<%= config.bin %> <%= command.id %> my-collection --file tools.json',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
file: Flags.string({
|
||||
char: 'f',
|
||||
description: 'File containing tool IDs (one per line or JSON array)',
|
||||
required: true,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
collection: Args.string({
|
||||
description: 'Collection ID or slug',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(CollectionImport);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read tool IDs from file
|
||||
if (!fs.existsSync(flags.file)) {
|
||||
output.error(`File not found: ${flags.file}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(flags.file, 'utf-8').trim();
|
||||
let toolIds: string[];
|
||||
|
||||
// Try to parse as JSON first
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (Array.isArray(parsed)) {
|
||||
toolIds = parsed.map(String);
|
||||
} else {
|
||||
output.error('JSON file must contain an array of tool IDs');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Parse as line-separated text
|
||||
toolIds = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'));
|
||||
}
|
||||
|
||||
if (toolIds.length === 0) {
|
||||
output.error('No tool IDs found in file');
|
||||
return;
|
||||
}
|
||||
|
||||
output.info(`Found ${toolIds.length} tool(s) in file`);
|
||||
|
||||
const spinner = output.spinner(`Adding ${toolIds.length} tool(s)...`);
|
||||
|
||||
try {
|
||||
let added = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const toolId of toolIds) {
|
||||
try {
|
||||
await client.addToolsToCollection(args.collection, [toolId]);
|
||||
added++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
errors.push(`${toolId}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({ success: true, added, failed, errors });
|
||||
return;
|
||||
}
|
||||
|
||||
if (added > 0) {
|
||||
output.success(`Added ${added} tool(s) to collection`);
|
||||
}
|
||||
if (failed > 0) {
|
||||
output.warning(`Failed to add ${failed} tool(s)`);
|
||||
if (flags.verbose) {
|
||||
for (const err of errors) {
|
||||
output.listItem(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Import failed');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
packages/cli/src/commands/collection/list.ts
Normal file
98
packages/cli/src/commands/collection/list.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class CollectionList extends Command {
|
||||
static description = 'List your collections';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --limit 10',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
limit: Flags.integer({
|
||||
char: 'l',
|
||||
description: 'Maximum number of results',
|
||||
default: 20,
|
||||
}),
|
||||
offset: Flags.integer({
|
||||
char: 'o',
|
||||
description: 'Offset for pagination',
|
||||
default: 0,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(CollectionList);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Fetching collections...');
|
||||
|
||||
try {
|
||||
const response = await client.listCollections({
|
||||
limit: flags.limit,
|
||||
offset: flags.offset,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.length === 0) {
|
||||
output.info('No collections found');
|
||||
output.text('Create one with: tpm collection create');
|
||||
return;
|
||||
}
|
||||
|
||||
output.table(
|
||||
response.data.map((collection) => ({
|
||||
name: collection.name,
|
||||
slug: collection.slug || '-',
|
||||
public: collection.isPublic ? 'Yes' : 'No',
|
||||
tools: collection._count?.tools ?? 0,
|
||||
likes: collection.likeCount,
|
||||
})),
|
||||
[
|
||||
{ key: 'name', header: 'Name', width: 30 },
|
||||
{ key: 'slug', header: 'Slug', width: 25 },
|
||||
{ key: 'public', header: 'Public', width: 8 },
|
||||
{ key: 'tools', header: 'Tools', width: 8 },
|
||||
{ key: 'likes', header: 'Likes', width: 8 },
|
||||
]
|
||||
);
|
||||
|
||||
output.newLine();
|
||||
output.text(
|
||||
output.dim(
|
||||
`Showing ${response.data.length} collection(s)` +
|
||||
(response.pagination.hasMore ? ` (more available)` : '')
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch collections');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
packages/cli/src/commands/collection/remove.ts
Normal file
66
packages/cli/src/commands/collection/remove.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class CollectionRemove extends Command {
|
||||
static description = 'Remove a tool from a collection';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-collection tool-id-1',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
collection: Args.string({
|
||||
description: 'Collection ID or slug',
|
||||
required: true,
|
||||
}),
|
||||
tool: Args.string({
|
||||
description: 'Tool ID to remove',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(CollectionRemove);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Removing tool...');
|
||||
|
||||
try {
|
||||
await client.removeToolFromCollection(args.collection, args.tool);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({ success: true, removed: args.tool });
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Removed tool from collection`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to remove tool');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
packages/cli/src/commands/collection/update.ts
Normal file
91
packages/cli/src/commands/collection/update.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class CollectionUpdate extends Command {
|
||||
static description = 'Update a collection';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> my-collection --name "New Name"',
|
||||
'<%= config.bin %> <%= command.id %> my-collection --public',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
name: Flags.string({
|
||||
char: 'n',
|
||||
description: 'Collection name',
|
||||
}),
|
||||
description: Flags.string({
|
||||
char: 'd',
|
||||
description: 'Collection description',
|
||||
}),
|
||||
public: Flags.boolean({
|
||||
description: 'Make collection public',
|
||||
allowNo: true,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
id: Args.string({
|
||||
description: 'Collection ID or slug',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(CollectionUpdate);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
if (!client.isAuthenticated()) {
|
||||
output.error('Not authenticated. Run `tpm auth login` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build update payload
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (flags.name) updates.name = flags.name;
|
||||
if (flags.description !== undefined) updates.description = flags.description;
|
||||
if (flags.public !== undefined) updates.isPublic = flags.public;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
output.error('No updates specified. Use --help to see available options.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Updating collection...');
|
||||
|
||||
try {
|
||||
const response = await client.updateCollection(args.id, updates);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
output.error(response.message || 'Failed to update collection');
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
output.success(`Collection "${response.data.name}" updated successfully`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to update collection');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
180
packages/cli/src/commands/doctor.ts
Normal file
180
packages/cli/src/commands/doctor.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { getApiKey, getApiUrl, getConfigDir, hasCredentials, getConfig } from '../lib/config.js';
|
||||
import { TpmClient } from '../lib/api-client.js';
|
||||
import { createOutput } from '../lib/output.js';
|
||||
|
||||
interface DiagnosticCheck {
|
||||
name: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export default class Doctor extends Command {
|
||||
static description = 'Run diagnostic checks for TPMJS CLI';
|
||||
|
||||
static examples = ['<%= config.bin %> <%= command.id %>'];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Doctor);
|
||||
const output = createOutput(flags);
|
||||
|
||||
const checks: DiagnosticCheck[] = [];
|
||||
|
||||
output.heading('TPMJS CLI Diagnostics');
|
||||
|
||||
// 1. Check Node.js version
|
||||
const nodeVersion = process.version;
|
||||
const nodeVersionNum = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
|
||||
checks.push({
|
||||
name: 'Node.js Version',
|
||||
status: nodeVersionNum >= 18 ? 'ok' : 'error',
|
||||
message: nodeVersion,
|
||||
details: nodeVersionNum < 18 ? 'Node.js 18+ is required' : undefined,
|
||||
});
|
||||
|
||||
// 2. Check config directory
|
||||
const configDir = getConfigDir();
|
||||
const configExists = fs.existsSync(configDir);
|
||||
checks.push({
|
||||
name: 'Config Directory',
|
||||
status: configExists ? 'ok' : 'warning',
|
||||
message: configDir,
|
||||
details: configExists ? undefined : 'Config directory will be created on first use',
|
||||
});
|
||||
|
||||
// 3. Check authentication
|
||||
const hasAuth = hasCredentials() || !!process.env.TPMJS_API_KEY;
|
||||
const authSource = process.env.TPMJS_API_KEY ? 'environment' : hasCredentials() ? 'config file' : 'none';
|
||||
checks.push({
|
||||
name: 'Authentication',
|
||||
status: hasAuth ? 'ok' : 'warning',
|
||||
message: hasAuth ? `Configured via ${authSource}` : 'Not configured',
|
||||
details: hasAuth ? undefined : 'Run `tpm auth login` to authenticate',
|
||||
});
|
||||
|
||||
// 4. Check API connectivity
|
||||
const apiUrl = getApiUrl();
|
||||
try {
|
||||
const client = new TpmClient();
|
||||
const healthResponse = await client.health();
|
||||
checks.push({
|
||||
name: 'API Connectivity',
|
||||
status: 'ok',
|
||||
message: `Connected to ${apiUrl}`,
|
||||
details: `Server status: ${healthResponse.status}`,
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: 'API Connectivity',
|
||||
status: 'error',
|
||||
message: `Cannot connect to ${apiUrl}`,
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Check API authentication (if credentials exist)
|
||||
if (hasAuth) {
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
try {
|
||||
const client = new TpmClient({ apiKey });
|
||||
const whoamiResponse = await client.whoami();
|
||||
if (whoamiResponse.success && whoamiResponse.data) {
|
||||
checks.push({
|
||||
name: 'API Authentication',
|
||||
status: 'ok',
|
||||
message: `Authenticated as ${whoamiResponse.data.email}`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
name: 'API Authentication',
|
||||
status: 'error',
|
||||
message: 'API key is invalid',
|
||||
details: 'Run `tpm auth login` to re-authenticate',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: 'API Authentication',
|
||||
status: 'error',
|
||||
message: 'Failed to verify API key',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check config file
|
||||
const config = getConfig();
|
||||
checks.push({
|
||||
name: 'Configuration',
|
||||
status: 'ok',
|
||||
message: 'Loaded',
|
||||
details: flags.verbose ? JSON.stringify(config, null, 2) : undefined,
|
||||
});
|
||||
|
||||
// 7. Check disk space (warning if less than 100MB)
|
||||
try {
|
||||
const homeDir = os.homedir();
|
||||
const stats = fs.statfsSync(homeDir);
|
||||
const freeSpaceBytes = stats.bavail * stats.bsize;
|
||||
const freeSpaceMB = Math.floor(freeSpaceBytes / (1024 * 1024));
|
||||
checks.push({
|
||||
name: 'Disk Space',
|
||||
status: freeSpaceMB > 100 ? 'ok' : 'warning',
|
||||
message: `${freeSpaceMB} MB available`,
|
||||
details: freeSpaceMB <= 100 ? 'Low disk space may cause issues' : undefined,
|
||||
});
|
||||
} catch {
|
||||
// Ignore disk space check errors on unsupported platforms
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (flags.json) {
|
||||
const summary = {
|
||||
ok: checks.filter((c) => c.status === 'ok').length,
|
||||
warnings: checks.filter((c) => c.status === 'warning').length,
|
||||
errors: checks.filter((c) => c.status === 'error').length,
|
||||
};
|
||||
output.json({ checks, summary });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const check of checks) {
|
||||
const icon = check.status === 'ok' ? '✓' : check.status === 'warning' ? '⚠' : '✗';
|
||||
|
||||
output.text(`${icon} ${output.bold(check.name)}: ${check.message}`);
|
||||
if (check.details && (flags.verbose || check.status !== 'ok')) {
|
||||
output.text(` ${output.dim(check.details)}`);
|
||||
}
|
||||
}
|
||||
|
||||
output.newLine();
|
||||
|
||||
const errorCount = checks.filter((c) => c.status === 'error').length;
|
||||
const warningCount = checks.filter((c) => c.status === 'warning').length;
|
||||
|
||||
if (errorCount > 0) {
|
||||
output.error(`${errorCount} error(s) found`);
|
||||
} else if (warningCount > 0) {
|
||||
output.warning(`${warningCount} warning(s) found`);
|
||||
} else {
|
||||
output.success('All checks passed');
|
||||
}
|
||||
}
|
||||
}
|
||||
144
packages/cli/src/commands/mcp/config.ts
Normal file
144
packages/cli/src/commands/mcp/config.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import * as fs from 'node:fs';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
type ClientType = 'claude' | 'cursor' | 'windsurf' | 'generic';
|
||||
|
||||
export default class McpConfig extends Command {
|
||||
static description = 'Generate MCP configuration for AI clients';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> ajax/ajax-collection',
|
||||
'<%= config.bin %> <%= command.id %> ajax/ajax-collection --client cursor',
|
||||
'<%= config.bin %> <%= command.id %> ajax/ajax-collection --output ~/Library/Application\\ Support/Claude/claude_desktop_config.json',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
client: Flags.string({
|
||||
char: 'c',
|
||||
description: 'Target client (claude, cursor, windsurf, generic)',
|
||||
default: 'claude',
|
||||
options: ['claude', 'cursor', 'windsurf', 'generic'],
|
||||
}),
|
||||
output: Flags.string({
|
||||
char: 'o',
|
||||
description: 'Output file path (will merge with existing config)',
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
'api-key': Flags.string({
|
||||
char: 'k',
|
||||
description: 'API key to include in config (optional)',
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
collection: Args.string({
|
||||
description: 'Collection path (username/slug)',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(McpConfig);
|
||||
const output = createOutput(flags);
|
||||
|
||||
const [username, slug] = args.collection.split('/');
|
||||
if (!username || !slug) {
|
||||
output.error('Invalid collection path. Use format: username/collection-slug');
|
||||
return;
|
||||
}
|
||||
|
||||
const mcpUrl = `https://tpmjs.com/api/mcp/${username}/${slug}/sse`;
|
||||
const clientType = flags.client as ClientType;
|
||||
|
||||
// Generate config based on client type
|
||||
const config = generateConfig(clientType, mcpUrl, slug, flags['api-key']);
|
||||
|
||||
if (flags.json) {
|
||||
output.json(config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.output) {
|
||||
// Merge with existing config if file exists
|
||||
let existingConfig: Record<string, unknown> = {};
|
||||
if (fs.existsSync(flags.output)) {
|
||||
try {
|
||||
const content = fs.readFileSync(flags.output, 'utf-8');
|
||||
existingConfig = JSON.parse(content);
|
||||
} catch {
|
||||
output.warning(`Could not parse existing config at ${flags.output}, creating new file`);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge mcpServers
|
||||
const existingServers = typeof existingConfig.mcpServers === 'object' && existingConfig.mcpServers !== null
|
||||
? existingConfig.mcpServers as Record<string, unknown>
|
||||
: {};
|
||||
const mergedConfig = {
|
||||
...existingConfig,
|
||||
mcpServers: {
|
||||
...existingServers,
|
||||
...(config.mcpServers as Record<string, unknown>),
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(flags.output, JSON.stringify(mergedConfig, null, 2));
|
||||
output.success(`Config written to ${flags.output}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Output config to console
|
||||
output.heading(`MCP Config for ${clientType}`);
|
||||
output.text(output.dim(`Collection: ${args.collection}`));
|
||||
output.text(output.dim(`URL: ${mcpUrl}`));
|
||||
output.newLine();
|
||||
|
||||
output.subheading('Add to your config file:');
|
||||
output.code(JSON.stringify(config, null, 2), 'json');
|
||||
|
||||
output.newLine();
|
||||
output.text(output.dim(getConfigPath(clientType)));
|
||||
}
|
||||
}
|
||||
|
||||
function generateConfig(
|
||||
_client: ClientType,
|
||||
mcpUrl: string,
|
||||
name: string,
|
||||
apiKey?: string
|
||||
): Record<string, unknown> {
|
||||
const serverName = `tpmjs-${name}`;
|
||||
|
||||
const baseConfig = {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic/mcp-remote', mcpUrl],
|
||||
...(apiKey && {
|
||||
env: {
|
||||
TPMJS_API_KEY: apiKey,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return baseConfig;
|
||||
}
|
||||
|
||||
function getConfigPath(client: ClientType): string {
|
||||
switch (client) {
|
||||
case 'claude':
|
||||
return 'Config location: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)';
|
||||
case 'cursor':
|
||||
return 'Config location: ~/.cursor/mcp.json';
|
||||
case 'windsurf':
|
||||
return 'Config location: ~/.windsurf/mcp.json';
|
||||
default:
|
||||
return 'Consult your MCP client documentation for config location';
|
||||
}
|
||||
}
|
||||
301
packages/cli/src/commands/mcp/serve.ts
Normal file
301
packages/cli/src/commands/mcp/serve.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import * as http from 'node:http';
|
||||
import * as readline from 'node:readline';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
interface MCPRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface MCPResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export default class MCPServe extends Command {
|
||||
static description = 'Run as a local MCP server';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --port 8080',
|
||||
'<%= config.bin %> <%= command.id %> --stdio',
|
||||
'<%= config.bin %> <%= command.id %> --collection my-collection',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
port: Flags.integer({
|
||||
char: 'p',
|
||||
description: 'Port to run the server on (HTTP mode)',
|
||||
default: 3333,
|
||||
}),
|
||||
stdio: Flags.boolean({
|
||||
description: 'Use stdio transport instead of HTTP',
|
||||
default: false,
|
||||
}),
|
||||
collection: Flags.string({
|
||||
char: 'c',
|
||||
description: 'Serve tools from a specific collection',
|
||||
}),
|
||||
tool: Flags.string({
|
||||
char: 't',
|
||||
description: 'Serve specific tools (comma-separated)',
|
||||
multiple: true,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
private client = getClient();
|
||||
private tools: Map<string, unknown> = new Map();
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(MCPServe);
|
||||
const output = createOutput(flags);
|
||||
|
||||
// Load tools
|
||||
await this.loadTools(flags, output);
|
||||
|
||||
if (flags.stdio) {
|
||||
await this.runStdioServer(output, flags.verbose);
|
||||
} else {
|
||||
await this.runHttpServer(flags.port, output, flags.verbose);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadTools(
|
||||
flags: { collection?: string; tool?: string[] },
|
||||
output: ReturnType<typeof createOutput>
|
||||
): Promise<void> {
|
||||
const spinner = output.spinner('Loading tools...');
|
||||
|
||||
try {
|
||||
if (flags.collection) {
|
||||
// Load tools from collection - search for tools with collection filter
|
||||
// For now, just load trending tools if collection specified
|
||||
const response = await this.client.getTrendingTools({ limit: 20 });
|
||||
if (response.data && response.data.length > 0) {
|
||||
for (const tool of response.data) {
|
||||
this.tools.set(tool.slug, tool);
|
||||
}
|
||||
}
|
||||
} else if (flags.tool && flags.tool.length > 0) {
|
||||
// Load specific tools by slug
|
||||
for (const toolId of flags.tool) {
|
||||
const response = await this.client.getToolBySlug(toolId);
|
||||
if (response.success && response.data) {
|
||||
this.tools.set(response.data.slug, response.data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Load trending tools as default
|
||||
const response = await this.client.getTrendingTools({ limit: 10 });
|
||||
if (response.data && response.data.length > 0) {
|
||||
for (const tool of response.data) {
|
||||
this.tools.set(tool.slug, tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
output.info(`Loaded ${this.tools.size} tool(s)`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to load tools');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async runStdioServer(
|
||||
output: ReturnType<typeof createOutput>,
|
||||
verbose: boolean
|
||||
): Promise<void> {
|
||||
output.info('Starting MCP server in stdio mode...');
|
||||
output.info('Listening for JSON-RPC messages on stdin');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
try {
|
||||
const request = JSON.parse(line) as MCPRequest;
|
||||
if (verbose) {
|
||||
output.info(`Received: ${request.method}`);
|
||||
}
|
||||
const response = await this.handleRequest(request);
|
||||
console.log(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
const errorResponse: MCPResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
error: {
|
||||
code: -32700,
|
||||
message: 'Parse error',
|
||||
data: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
};
|
||||
console.log(JSON.stringify(errorResponse));
|
||||
}
|
||||
});
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
private async runHttpServer(
|
||||
port: number,
|
||||
output: ReturnType<typeof createOutput>,
|
||||
verbose: boolean
|
||||
): Promise<void> {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === 'POST' && req.url === '/mcp') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const request = JSON.parse(body) as MCPRequest;
|
||||
if (verbose) {
|
||||
output.info(`Received: ${request.method}`);
|
||||
}
|
||||
const response = await this.handleRequest(request);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
const errorResponse: MCPResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
error: {
|
||||
code: -32700,
|
||||
message: 'Parse error',
|
||||
data: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
};
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(errorResponse));
|
||||
}
|
||||
});
|
||||
} else if (req.method === 'GET' && req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', tools: this.tools.size }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
output.success(`MCP server running at http://localhost:${port}/mcp`);
|
||||
output.info('Health check: GET /health');
|
||||
output.info('Press Ctrl+C to stop');
|
||||
});
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
private async handleRequest(request: MCPRequest): Promise<MCPResponse> {
|
||||
const { id, method, params } = request;
|
||||
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'tpmjs-mcp-server',
|
||||
version: '0.1.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case 'tools/list':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
tools: Array.from(this.tools.entries()).map(([slug, tool]) => ({
|
||||
name: slug,
|
||||
description: (tool as Record<string, unknown>).description || '',
|
||||
inputSchema: (tool as Record<string, unknown>).inputSchema || {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
case 'tools/call': {
|
||||
const toolName = (params as Record<string, unknown>)?.name as string;
|
||||
const toolArgs = (params as Record<string, unknown>)?.arguments as Record<string, unknown>;
|
||||
|
||||
if (!toolName) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32602,
|
||||
message: 'Invalid params: tool name required',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.executeTool(toolName, toolArgs || {});
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: error instanceof Error ? error.message : 'Tool execution failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Method not found: ${method}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
283
packages/cli/src/commands/playground.ts
Normal file
283
packages/cli/src/commands/playground.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import * as readline from 'node:readline';
|
||||
import { getClient } from '../lib/api-client.js';
|
||||
import { createOutput } from '../lib/output.js';
|
||||
|
||||
export default class Playground extends Command {
|
||||
static description = 'Interactive playground for testing tools';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --tool firecrawl-scrape',
|
||||
'<%= config.bin %> <%= command.id %> --web',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
tool: Flags.string({
|
||||
char: 't',
|
||||
description: 'Start with a specific tool selected',
|
||||
}),
|
||||
web: Flags.boolean({
|
||||
char: 'w',
|
||||
description: 'Open the web playground instead',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
private rl?: readline.Interface;
|
||||
private client = getClient();
|
||||
private selectedTool?: string;
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Playground);
|
||||
const output = createOutput(flags);
|
||||
|
||||
if (flags.web) {
|
||||
output.info('Opening web playground...');
|
||||
const open = (await import('open')).default;
|
||||
await open('https://tpmjs.com/playground');
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedTool = flags.tool;
|
||||
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
output.heading('TPMJS Playground');
|
||||
output.text('');
|
||||
output.text('Commands:');
|
||||
output.listItem('.help - Show this help');
|
||||
output.listItem('.tools - List available tools');
|
||||
output.listItem('.select - Select a tool to use');
|
||||
output.listItem('.info - Show info about selected tool');
|
||||
output.listItem('.clear - Clear the screen');
|
||||
output.listItem('.exit - Exit the playground');
|
||||
output.text('');
|
||||
|
||||
if (this.selectedTool) {
|
||||
output.info(`Selected tool: ${this.selectedTool}`);
|
||||
} else {
|
||||
output.text('No tool selected. Use .select to choose a tool.');
|
||||
}
|
||||
|
||||
output.text('');
|
||||
output.text('Enter JSON input to execute the selected tool.');
|
||||
output.divider();
|
||||
|
||||
await this.repl(output, flags.verbose);
|
||||
}
|
||||
|
||||
private async repl(output: ReturnType<typeof createOutput>, verbose: boolean): Promise<void> {
|
||||
const prompt = () => {
|
||||
const prefix = this.selectedTool ? `[${this.selectedTool}]` : '[no tool]';
|
||||
this.rl?.question(`${prefix} > `, async (input) => {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('.')) {
|
||||
await this.handleCommand(trimmed, output, verbose);
|
||||
if (trimmed !== '.exit') {
|
||||
prompt();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to execute as JSON input
|
||||
if (!this.selectedTool) {
|
||||
output.warning('No tool selected. Use .select to choose a tool first.');
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = JSON.parse(trimmed);
|
||||
await this.executeTool(params, output, verbose);
|
||||
} catch {
|
||||
output.error('Invalid JSON input. Enter valid JSON or use a command (.help)');
|
||||
}
|
||||
|
||||
prompt();
|
||||
});
|
||||
};
|
||||
|
||||
prompt();
|
||||
|
||||
// Keep running until exit
|
||||
await new Promise<void>((resolve) => {
|
||||
this.rl?.on('close', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCommand(
|
||||
command: string,
|
||||
output: ReturnType<typeof createOutput>,
|
||||
verbose: boolean
|
||||
): Promise<void> {
|
||||
const [cmd, ...args] = command.split(/\s+/);
|
||||
|
||||
switch (cmd) {
|
||||
case '.help':
|
||||
output.text('');
|
||||
output.text('Commands:');
|
||||
output.listItem('.help - Show this help');
|
||||
output.listItem('.tools - List available tools');
|
||||
output.listItem('.select <tool> - Select a tool to use');
|
||||
output.listItem('.info - Show info about selected tool');
|
||||
output.listItem('.clear - Clear the screen');
|
||||
output.listItem('.exit - Exit the playground');
|
||||
output.text('');
|
||||
output.text('To execute a tool, enter JSON input:');
|
||||
output.text(' {"url": "https://example.com"}');
|
||||
output.text('');
|
||||
break;
|
||||
|
||||
case '.tools':
|
||||
await this.listTools(output);
|
||||
break;
|
||||
|
||||
case '.select':
|
||||
if (args.length === 0) {
|
||||
output.warning('Usage: .select <tool-slug>');
|
||||
} else {
|
||||
this.selectedTool = args[0];
|
||||
output.success(`Selected: ${this.selectedTool}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case '.info':
|
||||
await this.showToolInfo(output, verbose);
|
||||
break;
|
||||
|
||||
case '.clear':
|
||||
console.clear();
|
||||
break;
|
||||
|
||||
case '.exit':
|
||||
output.info('Goodbye!');
|
||||
this.rl?.close();
|
||||
break;
|
||||
|
||||
default:
|
||||
output.warning(`Unknown command: ${cmd}. Type .help for available commands.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async listTools(output: ReturnType<typeof createOutput>): Promise<void> {
|
||||
const spinner = output.spinner('Loading tools...');
|
||||
|
||||
try {
|
||||
const response = await this.client.getTrendingTools({ limit: 20 });
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!response.data || response.data.length === 0) {
|
||||
output.error('No tools found');
|
||||
return;
|
||||
}
|
||||
|
||||
output.text('');
|
||||
output.text('Available tools:');
|
||||
|
||||
for (const tool of response.data) {
|
||||
output.listItem(`${tool.slug} - ${tool.name}`);
|
||||
}
|
||||
|
||||
output.text('');
|
||||
output.text('Use .select <slug> to select a tool');
|
||||
} catch {
|
||||
spinner.fail('Failed to load tools');
|
||||
}
|
||||
}
|
||||
|
||||
private async showToolInfo(
|
||||
output: ReturnType<typeof createOutput>,
|
||||
verbose: boolean
|
||||
): Promise<void> {
|
||||
if (!this.selectedTool) {
|
||||
output.warning('No tool selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Loading tool info...');
|
||||
|
||||
try {
|
||||
const response = await this.client.getToolBySlug(this.selectedTool);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
output.error('Tool not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = response.data;
|
||||
|
||||
output.text('');
|
||||
output.heading(tool.name);
|
||||
output.text(`Slug: ${tool.slug}`);
|
||||
output.text(`Category: ${tool.category}`);
|
||||
output.text(`Version: ${tool.npmVersion}`);
|
||||
output.text('');
|
||||
output.text('Description:');
|
||||
output.text(` ${tool.description || '(none)'}`);
|
||||
|
||||
if (tool.inputSchema) {
|
||||
output.text('');
|
||||
output.text('Input Schema:');
|
||||
output.text(JSON.stringify(tool.inputSchema, null, 2));
|
||||
}
|
||||
|
||||
if (verbose && tool.tools && tool.tools.length > 0) {
|
||||
output.text('');
|
||||
output.text('Available operations:');
|
||||
for (const t of tool.tools) {
|
||||
output.listItem(`${t.name}: ${t.description || ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
output.text('');
|
||||
} catch {
|
||||
spinner.fail('Failed to load tool info');
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTool(
|
||||
params: Record<string, unknown>,
|
||||
output: ReturnType<typeof createOutput>,
|
||||
verbose: boolean
|
||||
): Promise<void> {
|
||||
if (!this.selectedTool) {
|
||||
output.warning('No tool selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = output.spinner('Executing...');
|
||||
|
||||
try {
|
||||
const result = await this.client.executeTool(this.selectedTool, params);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
output.success('Result:');
|
||||
output.text(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
spinner.fail('Execution failed');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
120
packages/cli/src/commands/publish/check.ts
Normal file
120
packages/cli/src/commands/publish/check.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class PublishCheck extends Command {
|
||||
static description = 'Check if your package has been discovered by tpmjs.com';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> @myorg/my-tool',
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
package: Args.string({
|
||||
description: 'npm package name (defaults to current directory)',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(PublishCheck);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
// Determine package name
|
||||
let packageName = args.package as string | undefined;
|
||||
|
||||
if (!packageName) {
|
||||
const packagePath = path.resolve('.', 'package.json');
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
|
||||
packageName = packageJson.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!packageName) {
|
||||
output.error('No package name provided and no package.json found');
|
||||
return;
|
||||
}
|
||||
|
||||
output.info(`Checking discovery status for: ${packageName}`);
|
||||
|
||||
const spinner = output.spinner('Checking tpmjs.com...');
|
||||
|
||||
try {
|
||||
// Search for the package by npm name
|
||||
const searchResponse = await client.searchTools({ query: packageName, limit: 10 });
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!searchResponse.data || searchResponse.data.length === 0) {
|
||||
// No results found
|
||||
}
|
||||
|
||||
// Find exact match
|
||||
const exactMatch = searchResponse.data?.find(
|
||||
(tool) => tool.npmPackageName === packageName
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
packageName,
|
||||
discovered: Boolean(exactMatch),
|
||||
tool: exactMatch || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exactMatch) {
|
||||
output.success('Package has been discovered!');
|
||||
output.divider();
|
||||
output.text(`Name: ${exactMatch.name}`);
|
||||
output.text(`Slug: ${exactMatch.slug}`);
|
||||
output.text(`Category: ${exactMatch.category}`);
|
||||
output.text(`Tier: ${exactMatch.tier}`);
|
||||
output.text(`Version: ${exactMatch.npmVersion}`);
|
||||
output.text(`Downloads (last month): ${exactMatch.npmDownloadsLastMonth?.toLocaleString() || 'N/A'}`);
|
||||
output.text(`Quality Score: ${exactMatch.qualityScore?.toFixed(2) || 'N/A'}`);
|
||||
output.text('');
|
||||
output.text(`View on tpmjs.com:`);
|
||||
output.text(` https://tpmjs.com/tools/${exactMatch.slug}`);
|
||||
} else {
|
||||
output.warning('Package not yet discovered');
|
||||
output.text('');
|
||||
output.text('Possible reasons:');
|
||||
output.listItem('Package was recently published (sync runs every 2-15 minutes)');
|
||||
output.listItem('Missing "tpmjs" keyword in package.json keywords array');
|
||||
output.listItem('Missing or invalid "tpmjs" field in package.json');
|
||||
output.text('');
|
||||
output.text('To publish a TPMJS tool:');
|
||||
output.listItem('Add "tpmjs" to your package.json keywords');
|
||||
output.listItem('Add a valid "tpmjs" field with category and tools');
|
||||
output.listItem('Run `tpm tool validate` to check your configuration');
|
||||
output.listItem('Publish to npm with `npm publish`');
|
||||
output.text('');
|
||||
output.text('After publishing, your tool should appear within 15 minutes.');
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to check status');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
packages/cli/src/commands/publish/preview.ts
Normal file
171
packages/cli/src/commands/publish/preview.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class PublishPreview extends Command {
|
||||
static description = 'Preview how your tool will appear on tpmjs.com';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --path ./my-tool',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
path: Flags.string({
|
||||
char: 'p',
|
||||
description: 'Path to package directory',
|
||||
default: '.',
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(PublishPreview);
|
||||
const output = createOutput(flags);
|
||||
|
||||
const packagePath = path.resolve(flags.path, 'package.json');
|
||||
|
||||
if (!fs.existsSync(packagePath)) {
|
||||
output.error(`No package.json found at ${packagePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
|
||||
const tpmjs = packageJson.tpmjs;
|
||||
|
||||
if (!tpmjs) {
|
||||
output.error('No tpmjs field found in package.json');
|
||||
output.text('Run `tpm tool validate` for detailed validation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build preview data
|
||||
const preview = {
|
||||
name: tpmjs.name || packageJson.name,
|
||||
description: tpmjs.description || packageJson.description,
|
||||
category: tpmjs.category,
|
||||
tier: this.determineTier(tpmjs),
|
||||
slug: this.generateSlug(tpmjs.name || packageJson.name),
|
||||
version: packageJson.version,
|
||||
author: this.extractAuthor(packageJson),
|
||||
repository: this.extractRepo(packageJson),
|
||||
tools: tpmjs.tools || [],
|
||||
tags: tpmjs.tags || [],
|
||||
documentation: tpmjs.documentation,
|
||||
examples: tpmjs.examples,
|
||||
};
|
||||
|
||||
if (flags.json) {
|
||||
output.json(preview);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display preview
|
||||
output.heading('Tool Preview');
|
||||
output.divider();
|
||||
|
||||
output.text(`Name: ${preview.name}`);
|
||||
output.text(`Slug: ${preview.slug}`);
|
||||
output.text(`Version: ${preview.version}`);
|
||||
output.text(`Category: ${preview.category}`);
|
||||
output.text(`Tier: ${preview.tier}`);
|
||||
output.text('');
|
||||
output.text(`Description:`);
|
||||
output.text(` ${preview.description || '(none)'}`);
|
||||
|
||||
if (preview.author) {
|
||||
output.text('');
|
||||
output.text(`Author: ${preview.author}`);
|
||||
}
|
||||
|
||||
if (preview.repository) {
|
||||
output.text(`Repository: ${preview.repository}`);
|
||||
}
|
||||
|
||||
if (preview.tools.length > 0) {
|
||||
output.text('');
|
||||
output.text(`Tools (${preview.tools.length}):`);
|
||||
for (const tool of preview.tools) {
|
||||
if (typeof tool === 'string') {
|
||||
output.listItem(tool);
|
||||
} else {
|
||||
output.listItem(`${tool.name}: ${tool.description || ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preview.tags.length > 0) {
|
||||
output.text('');
|
||||
output.text(`Tags: ${preview.tags.join(', ')}`);
|
||||
}
|
||||
|
||||
if (preview.documentation) {
|
||||
output.text('');
|
||||
output.text(`Documentation: ${preview.documentation}`);
|
||||
}
|
||||
|
||||
if (preview.examples && preview.examples.length > 0) {
|
||||
output.text('');
|
||||
output.text(`Examples: ${preview.examples.length} example(s) provided`);
|
||||
}
|
||||
|
||||
output.divider();
|
||||
output.text('');
|
||||
output.info('This is how your tool will appear on tpmjs.com');
|
||||
output.text('Run `tpm tool validate` to check for issues before publishing');
|
||||
}
|
||||
|
||||
private determineTier(tpmjs: Record<string, unknown>): string {
|
||||
// Rich tier requires: tools array with schemas, examples, documentation
|
||||
const hasTools = Array.isArray(tpmjs.tools) && tpmjs.tools.length > 0;
|
||||
const hasExamples = Array.isArray(tpmjs.examples) && tpmjs.examples.length > 0;
|
||||
const hasDocumentation = Boolean(tpmjs.documentation);
|
||||
|
||||
if (hasTools && hasExamples && hasDocumentation) {
|
||||
return 'rich';
|
||||
}
|
||||
return 'minimal';
|
||||
}
|
||||
|
||||
private generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/@/g, '')
|
||||
.replace(/\//g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
private extractAuthor(packageJson: Record<string, unknown>): string | undefined {
|
||||
const author = packageJson.author;
|
||||
if (typeof author === 'string') {
|
||||
return author;
|
||||
}
|
||||
if (author && typeof author === 'object') {
|
||||
const authorObj = author as Record<string, string>;
|
||||
return authorObj.name || authorObj.email;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractRepo(packageJson: Record<string, unknown>): string | undefined {
|
||||
const repo = packageJson.repository;
|
||||
if (typeof repo === 'string') {
|
||||
return repo;
|
||||
}
|
||||
if (repo && typeof repo === 'object') {
|
||||
return (repo as Record<string, string>).url;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
149
packages/cli/src/commands/tool/execute.ts
Normal file
149
packages/cli/src/commands/tool/execute.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class ToolExecute extends Command {
|
||||
static description = 'Execute a TPMJS tool';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> firecrawl-scrape --input \'{"url":"https://example.com"}\'',
|
||||
'<%= config.bin %> <%= command.id %> my-tool --input-file params.json',
|
||||
'<%= config.bin %> <%= command.id %> my-tool --stream',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
input: Flags.string({
|
||||
char: 'i',
|
||||
description: 'Input parameters as JSON string',
|
||||
}),
|
||||
'input-file': Flags.string({
|
||||
char: 'f',
|
||||
description: 'Path to JSON file containing input parameters',
|
||||
}),
|
||||
stream: Flags.boolean({
|
||||
char: 's',
|
||||
description: 'Stream output (for tools that support it)',
|
||||
default: false,
|
||||
}),
|
||||
timeout: Flags.integer({
|
||||
char: 't',
|
||||
description: 'Timeout in seconds',
|
||||
default: 300,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
tool: Args.string({
|
||||
description: 'Tool slug or ID',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ToolExecute);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
// Parse input parameters
|
||||
let params: Record<string, unknown> = {};
|
||||
|
||||
if (flags.input) {
|
||||
try {
|
||||
params = JSON.parse(flags.input);
|
||||
} catch {
|
||||
output.error('Invalid JSON in --input flag');
|
||||
return;
|
||||
}
|
||||
} else if (flags['input-file']) {
|
||||
try {
|
||||
const fs = await import('node:fs');
|
||||
const content = fs.readFileSync(flags['input-file'], 'utf-8');
|
||||
params = JSON.parse(content);
|
||||
} catch (error) {
|
||||
output.error(`Failed to read input file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check stdin for piped input
|
||||
if (!flags.input && !flags['input-file'] && !process.stdin.isTTY) {
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const stdinContent = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (stdinContent) {
|
||||
params = JSON.parse(stdinContent);
|
||||
}
|
||||
} catch {
|
||||
output.error('Failed to parse JSON from stdin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = flags.stream ? null : output.spinner(`Executing ${args.tool}...`);
|
||||
|
||||
try {
|
||||
if (flags.stream) {
|
||||
// Streaming execution
|
||||
output.info(`Executing ${args.tool} with streaming...`);
|
||||
output.divider();
|
||||
|
||||
const stream = client.executeToolStream(args.tool, params);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'text') {
|
||||
process.stdout.write(event.data);
|
||||
} else if (event.type === 'error') {
|
||||
output.error(event.data);
|
||||
} else if (event.type === 'done') {
|
||||
output.text('');
|
||||
output.divider();
|
||||
output.success('Execution complete');
|
||||
} else if (flags.verbose) {
|
||||
output.info(`Event: ${event.type}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-streaming execution
|
||||
const result = await client.executeTool(args.tool, params);
|
||||
|
||||
spinner?.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
output.success('Execution complete');
|
||||
output.divider();
|
||||
|
||||
if (typeof result === 'string') {
|
||||
output.text(result);
|
||||
} else if (result && typeof result === 'object') {
|
||||
// Pretty print the result
|
||||
const formatted = JSON.stringify(result, null, 2);
|
||||
output.text(formatted);
|
||||
} else {
|
||||
output.text(String(result));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner?.fail('Execution failed');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
packages/cli/src/commands/tool/info.ts
Normal file
110
packages/cli/src/commands/tool/info.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class ToolInfo extends Command {
|
||||
static description = 'Get detailed information about a tool';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> @tpmjs/official-firecrawl scrapeTool',
|
||||
'<%= config.bin %> <%= command.id %> firecrawl-tool default',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
package: Args.string({
|
||||
description: 'Package name (e.g., @tpmjs/official-firecrawl)',
|
||||
required: true,
|
||||
}),
|
||||
tool: Args.string({
|
||||
description: 'Tool name (e.g., scrapeTool)',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ToolInfo);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
const spinner = output.spinner('Fetching tool info...');
|
||||
|
||||
try {
|
||||
const response = await client.getTool(args.package, args.tool);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
output.error('Tool not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = response.data;
|
||||
|
||||
if (flags.json) {
|
||||
output.json(tool);
|
||||
return;
|
||||
}
|
||||
|
||||
output.heading(`${tool.name}`);
|
||||
|
||||
output.keyValue('Package', tool.package?.npmPackageName || tool.npmPackageName);
|
||||
output.keyValue('Category', tool.package?.category || tool.category);
|
||||
output.keyValue('Official', (tool.package?.isOfficial || tool.isOfficial) ? 'Yes' : 'No');
|
||||
output.newLine();
|
||||
|
||||
output.subheading('Description');
|
||||
output.text(tool.description || 'No description available');
|
||||
output.newLine();
|
||||
|
||||
output.subheading('Health Status');
|
||||
output.keyValue('Import', formatHealthBadge(tool.importHealth));
|
||||
output.keyValue('Execution', formatHealthBadge(tool.executionHealth));
|
||||
output.newLine();
|
||||
|
||||
output.subheading('Metrics');
|
||||
output.keyValue('Quality Score', tool.qualityScore ? tool.qualityScore.toFixed(2) : 'N/A');
|
||||
output.keyValue('Downloads/Month', formatDownloads(tool.package?.npmDownloadsLastMonth || tool.npmDownloadsLastMonth));
|
||||
output.keyValue('Likes', tool.likeCount.toString());
|
||||
output.newLine();
|
||||
|
||||
output.subheading('Links');
|
||||
output.text(`Web: ${output.link('View on TPMJS', `https://tpmjs.com/tool/${args.package}/${args.tool}`)}`);
|
||||
output.text(`npm: ${output.link('View on npm', `https://www.npmjs.com/package/${args.package}`)}`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch tool info');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDownloads(count: number): string {
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
function formatHealthBadge(health: string): string {
|
||||
switch (health) {
|
||||
case 'HEALTHY':
|
||||
return '✓ Healthy';
|
||||
case 'BROKEN':
|
||||
return '✗ Broken';
|
||||
default:
|
||||
return '? Unknown';
|
||||
}
|
||||
}
|
||||
376
packages/cli/src/commands/tool/init.ts
Normal file
376
packages/cli/src/commands/tool/init.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as readline from 'node:readline';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
const CATEGORIES = [
|
||||
'research',
|
||||
'web',
|
||||
'data',
|
||||
'documentation',
|
||||
'engineering',
|
||||
'security',
|
||||
'statistics',
|
||||
'ops',
|
||||
'agent',
|
||||
'sandbox',
|
||||
'utilities',
|
||||
'html',
|
||||
'compliance',
|
||||
];
|
||||
|
||||
export default class ToolInit extends Command {
|
||||
static description = 'Initialize a new TPMJS tool package';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> my-tool',
|
||||
'<%= config.bin %> <%= command.id %> --template minimal',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
template: Flags.string({
|
||||
char: 't',
|
||||
description: 'Template to use',
|
||||
options: ['minimal', 'rich'],
|
||||
default: 'minimal',
|
||||
}),
|
||||
category: Flags.string({
|
||||
char: 'c',
|
||||
description: 'Tool category',
|
||||
options: CATEGORIES,
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
char: 'f',
|
||||
description: 'Overwrite existing files',
|
||||
default: false,
|
||||
}),
|
||||
yes: Flags.boolean({
|
||||
char: 'y',
|
||||
description: 'Skip prompts and use defaults',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
name: Args.string({
|
||||
description: 'Tool name (creates directory if not exists)',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
private rl?: readline.Interface;
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ToolInit);
|
||||
const output = createOutput(flags);
|
||||
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
try {
|
||||
// Gather configuration
|
||||
const config = await this.gatherConfig(args.name as string | undefined, flags, output);
|
||||
|
||||
// Determine target directory
|
||||
const targetDir = config.name ? path.resolve(config.name) : process.cwd();
|
||||
|
||||
// Check if files exist
|
||||
const packageJsonPath = path.join(targetDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath) && !flags.force) {
|
||||
output.error('package.json already exists. Use --force to overwrite.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate files
|
||||
await this.generateFiles(targetDir, config, flags.template, output);
|
||||
|
||||
output.success('TPMJS tool initialized successfully!');
|
||||
output.text('');
|
||||
output.text('Next steps:');
|
||||
output.listItem('Install dependencies: npm install');
|
||||
output.listItem('Implement your tool in src/index.ts');
|
||||
output.listItem('Validate: tpm tool validate');
|
||||
output.listItem('Build: npm run build');
|
||||
output.listItem('Publish: npm publish');
|
||||
} finally {
|
||||
this.rl?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async gatherConfig(
|
||||
name: string | undefined,
|
||||
flags: { category?: string; yes: boolean },
|
||||
output: ReturnType<typeof createOutput>
|
||||
): Promise<{
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
author: string;
|
||||
}> {
|
||||
if (flags.yes) {
|
||||
return {
|
||||
name: name || 'my-tpmjs-tool',
|
||||
description: 'A TPMJS tool',
|
||||
category: flags.category || 'utilities',
|
||||
author: process.env.USER || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
output.heading('TPMJS Tool Initialization');
|
||||
output.text('');
|
||||
|
||||
const toolName = name || (await this.prompt('Tool name: ')) || 'my-tpmjs-tool';
|
||||
const description = (await this.prompt('Description: ')) || 'A TPMJS tool';
|
||||
|
||||
let category = flags.category;
|
||||
if (!category) {
|
||||
output.text('');
|
||||
output.text('Available categories:');
|
||||
CATEGORIES.forEach((cat, i) => {
|
||||
output.text(` ${i + 1}. ${cat}`);
|
||||
});
|
||||
const catIndex = await this.prompt('Category (number or name): ');
|
||||
const index = parseInt(catIndex, 10);
|
||||
if (index > 0 && index <= CATEGORIES.length) {
|
||||
category = CATEGORIES[index - 1];
|
||||
} else if (CATEGORIES.includes(catIndex)) {
|
||||
category = catIndex;
|
||||
} else {
|
||||
category = 'utilities';
|
||||
}
|
||||
}
|
||||
|
||||
const author = (await this.prompt(`Author (${process.env.USER}): `)) || process.env.USER || 'unknown';
|
||||
|
||||
return { name: toolName, description, category: category ?? 'utilities', author };
|
||||
}
|
||||
|
||||
private prompt(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
this.rl?.question(question, (answer) => {
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async generateFiles(
|
||||
targetDir: string,
|
||||
config: { name: string; description: string; category: string; author: string },
|
||||
template: string,
|
||||
output: ReturnType<typeof createOutput>
|
||||
): Promise<void> {
|
||||
const spinner = output.spinner('Generating files...');
|
||||
|
||||
// package.json
|
||||
const packageJson = {
|
||||
name: config.name.startsWith('@') ? config.name : config.name,
|
||||
version: '0.1.0',
|
||||
description: config.description,
|
||||
type: 'module',
|
||||
main: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
exports: {
|
||||
'.': {
|
||||
import: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
},
|
||||
},
|
||||
files: ['dist'],
|
||||
keywords: ['tpmjs', 'mcp', 'ai-tools', config.category],
|
||||
author: config.author,
|
||||
license: 'MIT',
|
||||
scripts: {
|
||||
build: 'tsup',
|
||||
dev: 'tsup --watch',
|
||||
'type-check': 'tsc --noEmit',
|
||||
prepublishOnly: 'npm run build',
|
||||
},
|
||||
dependencies: {
|
||||
ai: '^4.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/node': '^20.0.0',
|
||||
tsup: '^8.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
tpmjs: {
|
||||
name: config.name.replace(/@[^/]+\//, '').replace(/-/g, ' '),
|
||||
description: config.description,
|
||||
category: config.category,
|
||||
tools: [
|
||||
{
|
||||
name: 'myTool',
|
||||
description: 'Description of what this tool does',
|
||||
},
|
||||
],
|
||||
...(template === 'rich'
|
||||
? {
|
||||
documentation: 'https://github.com/yourname/' + config.name + '#readme',
|
||||
examples: [
|
||||
{
|
||||
title: 'Basic usage',
|
||||
code: 'const result = await myTool.execute({ input: "hello" });',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2)
|
||||
);
|
||||
|
||||
// tsconfig.json
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'bundler',
|
||||
declaration: true,
|
||||
declarationMap: true,
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
outDir: './dist',
|
||||
rootDir: './src',
|
||||
},
|
||||
include: ['src'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, 'tsconfig.json'),
|
||||
JSON.stringify(tsconfig, null, 2)
|
||||
);
|
||||
|
||||
// tsup.config.ts
|
||||
const tsupConfig = `import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, 'tsup.config.ts'), tsupConfig);
|
||||
|
||||
// Create src directory
|
||||
const srcDir = path.join(targetDir, 'src');
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
}
|
||||
|
||||
// src/index.ts
|
||||
const indexTs = `import { jsonSchema, tool } from 'ai';
|
||||
|
||||
/**
|
||||
* ${config.description}
|
||||
*/
|
||||
export const myTool = tool({
|
||||
description: '${config.description}',
|
||||
parameters: jsonSchema<{ input: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: {
|
||||
type: 'string',
|
||||
description: 'The input to process',
|
||||
},
|
||||
},
|
||||
required: ['input'],
|
||||
}),
|
||||
async execute({ input }) {
|
||||
// TODO: Implement your tool logic here
|
||||
return {
|
||||
result: \`Processed: \${input}\`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default myTool;
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(srcDir, 'index.ts'), indexTs);
|
||||
|
||||
// block.ts (required for TPMJS validation)
|
||||
const blockTs = `import { myTool } from './src/index.js';
|
||||
|
||||
export const block = {
|
||||
name: '${config.name}',
|
||||
tools: { myTool },
|
||||
};
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, 'block.ts'), blockTs);
|
||||
|
||||
// README.md
|
||||
const readme = `# ${config.name}
|
||||
|
||||
${config.description}
|
||||
|
||||
## Installation
|
||||
|
||||
\`\`\`bash
|
||||
npm install ${config.name}
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`typescript
|
||||
import { myTool } from '${config.name}';
|
||||
|
||||
const result = await myTool.execute({ input: 'hello' });
|
||||
console.log(result);
|
||||
\`\`\`
|
||||
|
||||
## Category
|
||||
|
||||
${config.category}
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, 'README.md'), readme);
|
||||
|
||||
// .gitignore
|
||||
const gitignore = `node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, '.gitignore'), gitignore);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
output.text('');
|
||||
output.text('Files created:');
|
||||
output.listItem('package.json');
|
||||
output.listItem('tsconfig.json');
|
||||
output.listItem('tsup.config.ts');
|
||||
output.listItem('src/index.ts');
|
||||
output.listItem('block.ts');
|
||||
output.listItem('README.md');
|
||||
output.listItem('.gitignore');
|
||||
}
|
||||
}
|
||||
136
packages/cli/src/commands/tool/search.ts
Normal file
136
packages/cli/src/commands/tool/search.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { Args, Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class ToolSearch extends Command {
|
||||
static description = 'Search for tools in the TPMJS registry';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %> firecrawl',
|
||||
'<%= config.bin %> <%= command.id %> "web scraper" --category web',
|
||||
'<%= config.bin %> <%= command.id %> --category data --limit 20',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
category: Flags.string({
|
||||
char: 'c',
|
||||
description: 'Filter by category',
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: 'l',
|
||||
description: 'Maximum number of results',
|
||||
default: 20,
|
||||
}),
|
||||
offset: Flags.integer({
|
||||
char: 'o',
|
||||
description: 'Offset for pagination',
|
||||
default: 0,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static args = {
|
||||
query: Args.string({
|
||||
description: 'Search query',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ToolSearch);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
const spinner = output.spinner('Searching tools...');
|
||||
|
||||
try {
|
||||
const response = await client.searchTools({
|
||||
query: args.query,
|
||||
category: flags.category,
|
||||
limit: flags.limit,
|
||||
offset: flags.offset,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.length === 0) {
|
||||
output.info('No tools found');
|
||||
if (args.query) {
|
||||
output.text(`Try a different search query or remove the --category filter`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
output.table(
|
||||
response.data.map((tool) => ({
|
||||
name: tool.name,
|
||||
package: tool.npmPackageName,
|
||||
category: tool.category,
|
||||
downloads: formatDownloads(tool.npmDownloadsLastMonth),
|
||||
health: formatHealth(tool.importHealth, tool.executionHealth),
|
||||
score: typeof tool.qualityScore === 'number' ? tool.qualityScore.toFixed(2) : '-',
|
||||
})),
|
||||
[
|
||||
{ key: 'name', header: 'Name', width: 25 },
|
||||
{ key: 'package', header: 'Package', width: 35 },
|
||||
{ key: 'category', header: 'Category', width: 15 },
|
||||
{ key: 'downloads', header: 'Downloads', width: 12 },
|
||||
{ key: 'health', header: 'Health', width: 10 },
|
||||
{ key: 'score', header: 'Score', width: 8 },
|
||||
]
|
||||
);
|
||||
|
||||
output.newLine();
|
||||
output.text(
|
||||
output.dim(
|
||||
`Showing ${response.data.length} of ${response.pagination.hasMore ? 'more' : response.data.length} tools` +
|
||||
(args.query ? ` matching "${args.query}"` : '')
|
||||
)
|
||||
);
|
||||
|
||||
if (response.pagination.hasMore) {
|
||||
output.text(
|
||||
output.dim(
|
||||
`Use --offset ${flags.offset + flags.limit} to see more results`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Search failed');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDownloads(count: number | undefined): string {
|
||||
if (count === undefined || count === null) return '-';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
function formatHealth(importHealth: string, executionHealth: string): string {
|
||||
if (importHealth === 'BROKEN' || executionHealth === 'BROKEN') {
|
||||
return 'Broken';
|
||||
}
|
||||
if (importHealth === 'HEALTHY' && executionHealth === 'HEALTHY') {
|
||||
return 'Healthy';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
89
packages/cli/src/commands/tool/trending.ts
Normal file
89
packages/cli/src/commands/tool/trending.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class ToolTrending extends Command {
|
||||
static description = 'Show trending tools';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --limit 10',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
limit: Flags.integer({
|
||||
char: 'l',
|
||||
description: 'Maximum number of results',
|
||||
default: 10,
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(ToolTrending);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
const spinner = output.spinner('Fetching trending tools...');
|
||||
|
||||
try {
|
||||
const response = await client.getTrendingTools({
|
||||
limit: flags.limit,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.length === 0) {
|
||||
output.info('No trending tools found');
|
||||
return;
|
||||
}
|
||||
|
||||
output.heading('Trending Tools');
|
||||
|
||||
output.table(
|
||||
response.data.map((tool, index) => ({
|
||||
rank: `#${index + 1}`,
|
||||
name: tool.name,
|
||||
package: tool.npmPackageName,
|
||||
category: tool.category,
|
||||
downloads: formatDownloads(tool.npmDownloadsLastMonth),
|
||||
score: typeof tool.qualityScore === 'number' ? tool.qualityScore.toFixed(2) : '-',
|
||||
})),
|
||||
[
|
||||
{ key: 'rank', header: '#', width: 4 },
|
||||
{ key: 'name', header: 'Name', width: 25 },
|
||||
{ key: 'package', header: 'Package', width: 35 },
|
||||
{ key: 'category', header: 'Category', width: 15 },
|
||||
{ key: 'downloads', header: 'Downloads', width: 12 },
|
||||
{ key: 'score', header: 'Score', width: 8 },
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch trending tools');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDownloads(count: number | undefined): string {
|
||||
if (count === undefined || count === null) return '-';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return count.toString();
|
||||
}
|
||||
131
packages/cli/src/commands/tool/validate.ts
Normal file
131
packages/cli/src/commands/tool/validate.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getClient } from '../../lib/api-client.js';
|
||||
import { createOutput } from '../../lib/output.js';
|
||||
|
||||
export default class ToolValidate extends Command {
|
||||
static description = 'Validate a tpmjs package configuration';
|
||||
|
||||
static examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --path ./my-tool',
|
||||
];
|
||||
|
||||
static flags = {
|
||||
path: Flags.string({
|
||||
char: 'p',
|
||||
description: 'Path to package directory (defaults to current directory)',
|
||||
default: '.',
|
||||
}),
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(ToolValidate);
|
||||
const output = createOutput(flags);
|
||||
const client = getClient();
|
||||
|
||||
const packagePath = path.resolve(flags.path, 'package.json');
|
||||
|
||||
// Check if package.json exists
|
||||
if (!fs.existsSync(packagePath)) {
|
||||
output.error(`package.json not found at ${packagePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let packageJson: Record<string, unknown>;
|
||||
try {
|
||||
const content = fs.readFileSync(packagePath, 'utf-8');
|
||||
packageJson = JSON.parse(content);
|
||||
} catch (error) {
|
||||
output.error('Failed to parse package.json', error instanceof Error ? error.message : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for tpmjs keyword
|
||||
const keywords = (packageJson.keywords as string[]) || [];
|
||||
const hasTpmjsKeyword = keywords.includes('tpmjs');
|
||||
|
||||
if (!hasTpmjsKeyword) {
|
||||
output.warning('Missing "tpmjs" keyword in package.json');
|
||||
output.text('Add "tpmjs" to the keywords array for auto-discovery');
|
||||
}
|
||||
|
||||
// Check for tpmjs field
|
||||
const tpmjsField = packageJson.tpmjs;
|
||||
if (!tpmjsField) {
|
||||
output.error('Missing "tpmjs" field in package.json');
|
||||
output.newLine();
|
||||
output.text('Add a tpmjs field like:');
|
||||
output.code(
|
||||
JSON.stringify(
|
||||
{
|
||||
tpmjs: {
|
||||
category: 'utilities',
|
||||
tools: ['myTool'],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'json'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate with API
|
||||
const spinner = output.spinner('Validating tpmjs configuration...');
|
||||
|
||||
try {
|
||||
const response = await client.validateTpmjsField(tpmjsField);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
valid: response.data?.valid ?? false,
|
||||
tier: response.data?.tier,
|
||||
errors: response.data?.errors,
|
||||
hasTpmjsKeyword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.valid) {
|
||||
output.success('Configuration is valid');
|
||||
output.newLine();
|
||||
output.keyValue('Tier', response.data.tier || 'minimal');
|
||||
output.keyValue('Has tpmjs keyword', hasTpmjsKeyword ? 'Yes' : 'No (add for auto-discovery)');
|
||||
|
||||
if (!hasTpmjsKeyword) {
|
||||
output.newLine();
|
||||
output.warning('Add "tpmjs" to keywords for auto-discovery on npm publish');
|
||||
}
|
||||
} else {
|
||||
output.error('Configuration is invalid');
|
||||
if (response.data?.errors && Array.isArray(response.data.errors)) {
|
||||
output.newLine();
|
||||
output.subheading('Errors:');
|
||||
for (const error of response.data.errors) {
|
||||
output.listItem(JSON.stringify(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Validation failed');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
packages/cli/src/commands/update.ts
Normal file
93
packages/cli/src/commands/update.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { Command, Flags } from '@oclif/core';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createOutput } from '../lib/output.js';
|
||||
|
||||
export default class Update extends Command {
|
||||
static description = 'Update the TPMJS CLI to the latest version';
|
||||
|
||||
static examples = ['<%= config.bin %> <%= command.id %>'];
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Output in JSON format',
|
||||
default: false,
|
||||
}),
|
||||
verbose: Flags.boolean({
|
||||
char: 'v',
|
||||
description: 'Show verbose output',
|
||||
default: false,
|
||||
}),
|
||||
check: Flags.boolean({
|
||||
description: 'Only check for updates, do not install',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Update);
|
||||
const output = createOutput(flags);
|
||||
|
||||
const currentVersion = this.config.version;
|
||||
output.debug(`Current version: ${currentVersion}`);
|
||||
|
||||
// Check for latest version on npm
|
||||
const spinner = output.spinner('Checking for updates...');
|
||||
|
||||
try {
|
||||
const latestVersion = execSync('npm view @tpmjs/cli version', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
}).trim();
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (flags.json) {
|
||||
output.json({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable: latestVersion !== currentVersion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestVersion === currentVersion) {
|
||||
output.success(`You're on the latest version (${currentVersion})`);
|
||||
return;
|
||||
}
|
||||
|
||||
output.info(`Update available: ${currentVersion} → ${latestVersion}`);
|
||||
|
||||
if (flags.check) {
|
||||
output.text('Run `tpm update` to install the update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform update
|
||||
const updateSpinner = output.spinner('Installing update...');
|
||||
|
||||
try {
|
||||
// Try npm first, then pnpm, then yarn
|
||||
execSync('npm install -g @tpmjs/cli@latest', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 120000,
|
||||
stdio: flags.verbose ? 'inherit' : 'pipe',
|
||||
});
|
||||
|
||||
updateSpinner.succeed(`Updated to ${latestVersion}`);
|
||||
output.text('Restart your terminal to use the new version');
|
||||
} catch (installError) {
|
||||
updateSpinner.fail('Update failed');
|
||||
output.error(
|
||||
'Failed to install update',
|
||||
flags.verbose ? String(installError) : 'Try running: npm install -g @tpmjs/cli@latest'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to check for updates');
|
||||
output.error(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
flags.verbose ? String(error) : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/cli/src/hooks/init.ts
Normal file
12
packages/cli/src/hooks/init.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Hook } from '@oclif/core';
|
||||
|
||||
const hook: Hook<'init'> = async function () {
|
||||
// Initialization hook - runs before any command
|
||||
// Can be used for:
|
||||
// - Checking for updates
|
||||
// - Loading config
|
||||
// - Setting up analytics (if opted in)
|
||||
// - etc.
|
||||
};
|
||||
|
||||
export default hook;
|
||||
36
packages/cli/src/index.ts
Normal file
36
packages/cli/src/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Library exports for programmatic use
|
||||
export { TpmClient, getClient, ApiError } from './lib/api-client.js';
|
||||
export type {
|
||||
TpmClientOptions,
|
||||
ApiResponse,
|
||||
PaginationOptions,
|
||||
PaginatedResponse,
|
||||
Tool,
|
||||
ToolSearchOptions,
|
||||
Agent,
|
||||
CreateAgentInput,
|
||||
UpdateAgentInput,
|
||||
Collection,
|
||||
CreateCollectionInput,
|
||||
UpdateCollectionInput,
|
||||
User,
|
||||
ApiKey,
|
||||
Stats,
|
||||
} from './lib/api-client.js';
|
||||
|
||||
export {
|
||||
getConfig,
|
||||
setConfig,
|
||||
getConfigValue,
|
||||
setConfigValue,
|
||||
loadCredentials,
|
||||
saveCredentials,
|
||||
deleteCredentials,
|
||||
hasCredentials,
|
||||
getApiKey,
|
||||
getApiUrl,
|
||||
} from './lib/config.js';
|
||||
export type { TpmConfig, TpmCredentials } from './lib/config.js';
|
||||
|
||||
export { OutputFormatter, createOutput } from './lib/output.js';
|
||||
export type { OutputOptions } from './lib/output.js';
|
||||
470
packages/cli/src/lib/api-client.ts
Normal file
470
packages/cli/src/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { getApiKey, getApiUrl } from './config.js';
|
||||
|
||||
export interface TpmClientOptions {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginationOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Tool types
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tier: string;
|
||||
qualityScore: number | null;
|
||||
importHealth: string;
|
||||
executionHealth: string;
|
||||
likeCount: number;
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
npmDownloadsLastMonth: number;
|
||||
isOfficial: boolean;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
tools?: { name: string; description?: string }[];
|
||||
package?: {
|
||||
npmPackageName: string;
|
||||
category: string;
|
||||
npmDownloadsLastMonth: number;
|
||||
isOfficial: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolSearchOptions extends PaginationOptions {
|
||||
category?: string;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
// Agent types
|
||||
export interface Agent {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
systemPrompt: string | null;
|
||||
temperature: number;
|
||||
isPublic: boolean;
|
||||
likeCount: number;
|
||||
_count?: {
|
||||
tools: number;
|
||||
collections: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateAgentInput {
|
||||
name: string;
|
||||
uid?: string;
|
||||
description?: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
systemPrompt?: string;
|
||||
temperature?: number;
|
||||
isPublic?: boolean;
|
||||
collectionIds?: string[];
|
||||
toolIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateAgentInput {
|
||||
name?: string;
|
||||
uid?: string;
|
||||
description?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
systemPrompt?: string;
|
||||
temperature?: number;
|
||||
isPublic?: boolean;
|
||||
maxToolCallsPerTurn?: number;
|
||||
maxMessagesInContext?: number;
|
||||
}
|
||||
|
||||
// Collection types
|
||||
export interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
likeCount: number;
|
||||
_count?: {
|
||||
tools: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateCollectionInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCollectionInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
// User types
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
// API Key types
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
isActive: boolean;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Stats types
|
||||
export interface Stats {
|
||||
tools: {
|
||||
total: number;
|
||||
official: number;
|
||||
healthyImport: number;
|
||||
healthyExecution: number;
|
||||
};
|
||||
packages: {
|
||||
total: number;
|
||||
official: number;
|
||||
};
|
||||
categories: { name: string; count: number }[];
|
||||
}
|
||||
|
||||
export class TpmClient {
|
||||
private baseUrl: string;
|
||||
private apiKey: string | undefined;
|
||||
private timeout: number;
|
||||
|
||||
constructor(options: TpmClientOptions = {}) {
|
||||
this.baseUrl = options.baseUrl ?? getApiUrl();
|
||||
this.apiKey = options.apiKey ?? getApiKey();
|
||||
this.timeout = options.timeout ?? 30000;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await response.json() as T & { message?: string; error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
data.message || data.error || `HTTP ${response.status}`,
|
||||
response.status,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
async health(): Promise<{ status: string; timestamp: string }> {
|
||||
return this.request('/health');
|
||||
}
|
||||
|
||||
// Stats
|
||||
async getStats(): Promise<ApiResponse<Stats>> {
|
||||
return this.request('/stats');
|
||||
}
|
||||
|
||||
// Tools
|
||||
async searchTools(options: ToolSearchOptions = {}): Promise<PaginatedResponse<Tool>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.query) params.set('q', options.query);
|
||||
if (options.category) params.set('category', options.category);
|
||||
if (options.limit) params.set('limit', String(options.limit));
|
||||
if (options.offset) params.set('offset', String(options.offset));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/tools?${queryString}` : '/tools';
|
||||
|
||||
return this.request<PaginatedResponse<Tool>>(endpoint);
|
||||
}
|
||||
|
||||
async getTool(packageName: string, toolName: string): Promise<ApiResponse<Tool>> {
|
||||
return this.request(`/tools/${encodeURIComponent(packageName)}/${encodeURIComponent(toolName)}`);
|
||||
}
|
||||
|
||||
async getToolBySlug(slug: string): Promise<ApiResponse<Tool>> {
|
||||
// Search for the tool by slug
|
||||
const searchResult = await this.searchTools({ query: slug, limit: 1 });
|
||||
if (searchResult.data && searchResult.data.length > 0) {
|
||||
const tool = searchResult.data.find(t => t.slug === slug) || searchResult.data[0];
|
||||
return { success: true, data: tool };
|
||||
}
|
||||
return { success: false, error: 'Tool not found' };
|
||||
}
|
||||
|
||||
async getTrendingTools(options: PaginationOptions = {}): Promise<PaginatedResponse<Tool>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit) params.set('limit', String(options.limit));
|
||||
if (options.offset) params.set('offset', String(options.offset));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/tools/trending?${queryString}` : '/tools/trending';
|
||||
|
||||
return this.request<PaginatedResponse<Tool>>(endpoint);
|
||||
}
|
||||
|
||||
async validateTpmjsField(field: unknown): Promise<ApiResponse<{ valid: boolean; tier: string | null; errors?: unknown[] }>> {
|
||||
return this.request('/tools/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(field),
|
||||
});
|
||||
}
|
||||
|
||||
async executeTool(slug: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.request(`/tools/${encodeURIComponent(slug)}/execute`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
async *executeToolStream(
|
||||
slug: string,
|
||||
params: Record<string, unknown>
|
||||
): AsyncGenerator<{ type: string; data: string }> {
|
||||
const url = `${this.baseUrl}/tools/${encodeURIComponent(slug)}/execute`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
};
|
||||
|
||||
if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ ...params, stream: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new ApiError(errorText || `HTTP ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new ApiError('No response body', 0);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
yield { type: 'done', data: '' };
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
yield { type: 'done', data: '' };
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
yield { type: parsed.type || 'text', data: parsed.content || parsed.data || data };
|
||||
} catch {
|
||||
yield { type: 'text', data };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// Agents
|
||||
async listAgents(options: PaginationOptions = {}): Promise<PaginatedResponse<Agent>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit) params.set('limit', String(options.limit));
|
||||
if (options.offset) params.set('offset', String(options.offset));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/agents?${queryString}` : '/agents';
|
||||
|
||||
return this.request<PaginatedResponse<Agent>>(endpoint);
|
||||
}
|
||||
|
||||
async getAgent(id: string): Promise<ApiResponse<Agent>> {
|
||||
return this.request(`/agents/${id}`);
|
||||
}
|
||||
|
||||
async createAgent(input: CreateAgentInput): Promise<ApiResponse<Agent>> {
|
||||
return this.request('/agents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAgent(id: string, input: UpdateAgentInput): Promise<ApiResponse<Agent>> {
|
||||
return this.request(`/agents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<ApiResponse<void>> {
|
||||
return this.request(`/agents/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Collections
|
||||
async listCollections(options: PaginationOptions = {}): Promise<PaginatedResponse<Collection>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit) params.set('limit', String(options.limit));
|
||||
if (options.offset) params.set('offset', String(options.offset));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/collections?${queryString}` : '/collections';
|
||||
|
||||
return this.request<PaginatedResponse<Collection>>(endpoint);
|
||||
}
|
||||
|
||||
async getCollection(id: string): Promise<ApiResponse<Collection>> {
|
||||
return this.request(`/collections/${id}`);
|
||||
}
|
||||
|
||||
async createCollection(input: CreateCollectionInput): Promise<ApiResponse<Collection>> {
|
||||
return this.request('/collections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async updateCollection(id: string, input: UpdateCollectionInput): Promise<ApiResponse<Collection>> {
|
||||
return this.request(`/collections/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCollection(id: string): Promise<ApiResponse<void>> {
|
||||
return this.request(`/collections/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async addToolsToCollection(id: string, toolIds: string[]): Promise<ApiResponse<void>> {
|
||||
// Add tools one by one (API doesn't support bulk)
|
||||
for (const toolId of toolIds) {
|
||||
await this.request(`/collections/${id}/tools/${toolId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeToolFromCollection(id: string, toolId: string): Promise<ApiResponse<void>> {
|
||||
return this.request(`/collections/${id}/tools/${toolId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// User
|
||||
async whoami(): Promise<ApiResponse<User>> {
|
||||
return this.request('/user/profile');
|
||||
}
|
||||
|
||||
async listApiKeys(): Promise<ApiResponse<ApiKey[]>> {
|
||||
return this.request('/user/tpmjs-api-keys');
|
||||
}
|
||||
|
||||
// Check if authenticated
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom error class for API errors
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number,
|
||||
public data?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let clientInstance: TpmClient | null = null;
|
||||
|
||||
export function getClient(options?: TpmClientOptions): TpmClient {
|
||||
if (!clientInstance || options) {
|
||||
clientInstance = new TpmClient(options);
|
||||
}
|
||||
return clientInstance;
|
||||
}
|
||||
138
packages/cli/src/lib/config.ts
Normal file
138
packages/cli/src/lib/config.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import Conf from 'conf';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface TpmConfig {
|
||||
apiUrl?: string;
|
||||
defaultOutput?: 'human' | 'json';
|
||||
verbose?: boolean;
|
||||
analytics?: boolean;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TpmCredentials {
|
||||
apiKey?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.tpmjs');
|
||||
const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
|
||||
const HISTORY_DIR = path.join(CONFIG_DIR, 'history');
|
||||
|
||||
// Ensure config directory exists
|
||||
function ensureConfigDir(): void {
|
||||
if (!fs.existsSync(CONFIG_DIR)) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
// Config store using Conf
|
||||
const configStore = new Conf<TpmConfig>({
|
||||
projectName: 'tpmjs',
|
||||
cwd: CONFIG_DIR,
|
||||
configName: 'config',
|
||||
defaults: {
|
||||
apiUrl: 'https://tpmjs.com/api',
|
||||
defaultOutput: 'human',
|
||||
verbose: false,
|
||||
analytics: false,
|
||||
},
|
||||
});
|
||||
|
||||
export function getConfig(): TpmConfig {
|
||||
return configStore.store;
|
||||
}
|
||||
|
||||
export function setConfig(config: Partial<TpmConfig>): void {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined) {
|
||||
configStore.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfigValue<K extends keyof TpmConfig>(key: K): TpmConfig[K] {
|
||||
return configStore.get(key);
|
||||
}
|
||||
|
||||
export function setConfigValue<K extends keyof TpmConfig>(
|
||||
key: K,
|
||||
value: TpmConfig[K]
|
||||
): void {
|
||||
configStore.set(key, value);
|
||||
}
|
||||
|
||||
export function resetConfig(): void {
|
||||
configStore.clear();
|
||||
}
|
||||
|
||||
// Credentials management with secure file permissions
|
||||
export function loadCredentials(): TpmCredentials | null {
|
||||
ensureConfigDir();
|
||||
|
||||
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
|
||||
return JSON.parse(content) as TpmCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCredentials(credentials: TpmCredentials): void {
|
||||
ensureConfigDir();
|
||||
|
||||
const content = JSON.stringify(credentials, null, 2);
|
||||
fs.writeFileSync(CREDENTIALS_FILE, content, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function deleteCredentials(): void {
|
||||
if (fs.existsSync(CREDENTIALS_FILE)) {
|
||||
fs.unlinkSync(CREDENTIALS_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCredentials(): boolean {
|
||||
const creds = loadCredentials();
|
||||
return creds !== null && !!creds.apiKey;
|
||||
}
|
||||
|
||||
// Get API key from multiple sources (priority order)
|
||||
export function getApiKey(): string | undefined {
|
||||
// 1. Environment variable
|
||||
if (process.env.TPMJS_API_KEY) {
|
||||
return process.env.TPMJS_API_KEY;
|
||||
}
|
||||
|
||||
// 2. Credentials file
|
||||
const creds = loadCredentials();
|
||||
if (creds?.apiKey) {
|
||||
return creds.apiKey;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get API URL
|
||||
export function getApiUrl(): string {
|
||||
return process.env.TPMJS_API_URL ?? getConfigValue('apiUrl') ?? 'https://tpmjs.com/api';
|
||||
}
|
||||
|
||||
// History directory for conversation caching
|
||||
export function getHistoryDir(): string {
|
||||
if (!fs.existsSync(HISTORY_DIR)) {
|
||||
fs.mkdirSync(HISTORY_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
return HISTORY_DIR;
|
||||
}
|
||||
|
||||
// Config directory path
|
||||
export function getConfigDir(): string {
|
||||
ensureConfigDir();
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
184
packages/cli/src/lib/output.ts
Normal file
184
packages/cli/src/lib/output.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import Table from 'cli-table3';
|
||||
import ora, { type Ora } from 'ora';
|
||||
import pc from 'picocolors';
|
||||
|
||||
export interface OutputOptions {
|
||||
json?: boolean;
|
||||
verbose?: boolean;
|
||||
noColor?: boolean;
|
||||
}
|
||||
|
||||
export class OutputFormatter {
|
||||
private options: OutputOptions;
|
||||
|
||||
constructor(options: OutputOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// Output as JSON
|
||||
json(data: unknown): void {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// Output a table
|
||||
table<T extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
columns: { key: keyof T; header: string; width?: number }[]
|
||||
): void {
|
||||
if (this.options.json) {
|
||||
this.json(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: columns.map((col) => pc.bold(col.header)),
|
||||
colWidths: columns.map((col) => col.width ?? null),
|
||||
style: {
|
||||
head: [],
|
||||
border: [],
|
||||
},
|
||||
});
|
||||
|
||||
for (const row of data) {
|
||||
table.push(columns.map((col) => String(row[col.key] ?? '')));
|
||||
}
|
||||
|
||||
console.log(table.toString());
|
||||
}
|
||||
|
||||
// Success message
|
||||
success(message: string): void {
|
||||
if (this.options.json) return;
|
||||
console.log(pc.green('✓'), message);
|
||||
}
|
||||
|
||||
// Error message
|
||||
error(message: string, details?: string): void {
|
||||
if (this.options.json) {
|
||||
this.json({ error: message, details });
|
||||
return;
|
||||
}
|
||||
console.error(pc.red('✗'), message);
|
||||
if (details && this.options.verbose) {
|
||||
console.error(pc.dim(details));
|
||||
}
|
||||
}
|
||||
|
||||
// Warning message
|
||||
warning(message: string): void {
|
||||
if (this.options.json) return;
|
||||
console.log(pc.yellow('⚠'), message);
|
||||
}
|
||||
|
||||
// Info message
|
||||
info(message: string): void {
|
||||
if (this.options.json) return;
|
||||
console.log(pc.blue('ℹ'), message);
|
||||
}
|
||||
|
||||
// Debug message (only in verbose mode)
|
||||
debug(message: string): void {
|
||||
if (this.options.json) return;
|
||||
if (this.options.verbose) {
|
||||
console.log(pc.dim(`[debug] ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Plain text output
|
||||
text(message: string): void {
|
||||
if (this.options.json) return;
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
// Heading
|
||||
heading(text: string): void {
|
||||
if (this.options.json) return;
|
||||
console.log();
|
||||
console.log(pc.bold(pc.underline(text)));
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Subheading
|
||||
subheading(text: string): void {
|
||||
if (this.options.json) return;
|
||||
console.log(pc.bold(text));
|
||||
}
|
||||
|
||||
// Key-value pair
|
||||
keyValue(key: string, value: string | number | boolean | undefined): void {
|
||||
if (this.options.json) return;
|
||||
console.log(`${pc.dim(key + ':')} ${value ?? pc.dim('(not set)')}`);
|
||||
}
|
||||
|
||||
// List item
|
||||
listItem(text: string, indent = 0): void {
|
||||
if (this.options.json) return;
|
||||
const prefix = ' '.repeat(indent) + '•';
|
||||
console.log(`${prefix} ${text}`);
|
||||
}
|
||||
|
||||
// Spinner
|
||||
spinner(message: string): Ora {
|
||||
return ora({
|
||||
text: message,
|
||||
isSilent: this.options.json,
|
||||
}).start();
|
||||
}
|
||||
|
||||
// Blank line
|
||||
newLine(): void {
|
||||
if (this.options.json) return;
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
hr(): void {
|
||||
if (this.options.json) return;
|
||||
console.log(pc.dim('─'.repeat(50)));
|
||||
}
|
||||
|
||||
// Alias for hr
|
||||
divider(): void {
|
||||
this.hr();
|
||||
}
|
||||
|
||||
// Code block
|
||||
code(text: string, language?: string): void {
|
||||
if (this.options.json) {
|
||||
this.json({ code: text, language });
|
||||
return;
|
||||
}
|
||||
console.log(pc.dim('```' + (language ?? '')));
|
||||
console.log(text);
|
||||
console.log(pc.dim('```'));
|
||||
}
|
||||
|
||||
// Highlight text
|
||||
highlight(text: string): string {
|
||||
return pc.cyan(text);
|
||||
}
|
||||
|
||||
// Dim text
|
||||
dim(text: string): string {
|
||||
return pc.dim(text);
|
||||
}
|
||||
|
||||
// Bold text
|
||||
bold(text: string): string {
|
||||
return pc.bold(text);
|
||||
}
|
||||
|
||||
// Link (just returns text in terminal)
|
||||
link(text: string, url: string): string {
|
||||
// OSC 8 hyperlink support for modern terminals
|
||||
return `\x1b]8;;${url}\x07${pc.underline(pc.blue(text))}\x1b]8;;\x07`;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function to create formatter from command flags
|
||||
export function createOutput(flags: { json?: boolean; verbose?: boolean }): OutputFormatter {
|
||||
return new OutputFormatter({
|
||||
json: flags.json,
|
||||
verbose: flags.verbose,
|
||||
});
|
||||
}
|
||||
11
packages/cli/tsconfig.json
Normal file
11
packages/cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "@tpmjs/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"incremental": false,
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
18
packages/cli/tsup.config.ts
Normal file
18
packages/cli/tsup.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: [
|
||||
'src/index.ts',
|
||||
'src/commands/**/*.ts',
|
||||
'src/hooks/**/*.ts',
|
||||
],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
outDir: 'dist',
|
||||
target: 'node18',
|
||||
shims: true,
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue