diff --git a/apps/web/src/app/cli/auth/page.tsx b/apps/web/src/app/cli/auth/page.tsx new file mode 100644 index 0000000..9150368 --- /dev/null +++ b/apps/web/src/app/cli/auth/page.tsx @@ -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(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 ( +
+
+
+ +
+

Invalid Request

+

+ Missing required parameters. Please try authenticating again from the CLI. +

+ +
+
+ ); + } + + // Loading state + if (isPending) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + // Not signed in + if (!session?.user) { + const returnUrl = `/cli/auth?state=${encodeURIComponent(state)}&callback=${encodeURIComponent(callback)}`; + + return ( +
+
+
+ +
+

CLI Authentication

+

+ Sign in to authorize the TPMJS CLI to access your account. +

+ + + +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ); + } + + const user = session.user; + + // Signed in - show authorization prompt + return ( +
+
+
+
+ +
+

Authorize CLI Access

+

+ The TPMJS CLI is requesting access to your account. +

+
+ + {/* User info */} +
+
+ {user.image ? ( + + ) : ( +
+ +
+ )} +
+

+ {user.name || 'User'} +

+

+ {user.email} +

+
+
+
+ + {/* Permissions */} +
+

This will allow the CLI to:

+
    +
  • + + Access your collections and agents +
  • +
  • + + Execute tools on your behalf +
  • +
  • + + Manage your TPMJS resources +
  • +
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} +
+ + +
+ +

+ An API key will be created and sent to the CLI. + You can revoke it anytime from your dashboard. +

+
+
+ ); +} + +export default function CliAuthPage(): React.ReactElement { + return ( + + + + } + > + + + ); +} diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..9a60d56 --- /dev/null +++ b/packages/cli/README.md @@ -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 # 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 # Update an agent +tpm agent delete # Delete an agent +tpm agent chat # 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 # 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 diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js new file mode 100644 index 0000000..176d2af --- /dev/null +++ b/packages/cli/bin/run.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { execute } from '@oclif/core'; + +await execute({ dir: import.meta.url }); diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json new file mode 100644 index 0000000..bdd2659 --- /dev/null +++ b/packages/cli/oclif.manifest.json @@ -0,0 +1,1699 @@ +{ + "commands": { + "doctor": { + "aliases": [], + "args": {}, + "description": "Run diagnostic checks for TPMJS CLI", + "examples": [ + "<%= config.bin %> <%= command.id %>" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "doctor", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "doctor.js" + ] + }, + "playground": { + "aliases": [], + "args": {}, + "description": "Interactive playground for testing tools", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --tool firecrawl-scrape", + "<%= config.bin %> <%= command.id %> --web" + ], + "flags": { + "tool": { + "char": "t", + "description": "Start with a specific tool selected", + "name": "tool", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "web": { + "char": "w", + "description": "Open the web playground instead", + "name": "web", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "playground", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "playground.js" + ] + }, + "update": { + "aliases": [], + "args": {}, + "description": "Update the TPMJS CLI to the latest version", + "examples": [ + "<%= config.bin %> <%= command.id %>" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + }, + "check": { + "description": "Only check for updates, do not install", + "name": "check", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "update", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "update.js" + ] + }, + "agent:chat": { + "aliases": [], + "args": { + "agent": { + "description": "Agent ID or UID", + "name": "agent", + "required": true + }, + "message": { + "description": "Message to send (required unless --interactive)", + "name": "message" + } + }, + "description": "Chat with an agent", + "examples": [ + "<%= config.bin %> <%= command.id %> my-agent \"Hello!\"", + "<%= config.bin %> <%= command.id %> my-agent --interactive", + "<%= config.bin %> <%= command.id %> my-agent -i" + ], + "flags": { + "interactive": { + "char": "i", + "description": "Enter interactive chat mode (REPL)", + "name": "interactive", + "allowNo": false, + "type": "boolean" + }, + "conversation": { + "char": "c", + "description": "Continue existing conversation by ID", + "name": "conversation", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "agent:chat", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "agent", + "chat.js" + ] + }, + "agent:create": { + "aliases": [], + "args": {}, + "description": "Create a new agent", + "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" + ], + "flags": { + "name": { + "char": "n", + "description": "Agent name", + "name": "name", + "required": true, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "uid": { + "description": "Unique identifier (URL-friendly)", + "name": "uid", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "description": { + "char": "d", + "description": "Agent description", + "name": "description", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "provider": { + "char": "p", + "description": "AI provider (ANTHROPIC, OPENAI, GOOGLE, GROQ, MISTRAL)", + "name": "provider", + "required": true, + "hasDynamicHelp": false, + "multiple": false, + "options": [ + "ANTHROPIC", + "OPENAI", + "GOOGLE", + "GROQ", + "MISTRAL" + ], + "type": "option" + }, + "model": { + "char": "m", + "description": "Model ID", + "name": "model", + "required": true, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "system-prompt": { + "char": "s", + "description": "System prompt", + "name": "system-prompt", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "temperature": { + "char": "t", + "description": "Temperature (0-2)", + "name": "temperature", + "default": "0.7", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "public": { + "description": "Make agent public", + "name": "public", + "allowNo": false, + "type": "boolean" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "agent:create", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "agent", + "create.js" + ] + }, + "agent:delete": { + "aliases": [], + "args": { + "id": { + "description": "Agent ID or UID", + "name": "id", + "required": true + } + }, + "description": "Delete an agent", + "examples": [ + "<%= config.bin %> <%= command.id %> my-agent", + "<%= config.bin %> <%= command.id %> my-agent --force" + ], + "flags": { + "force": { + "char": "f", + "description": "Skip confirmation prompt", + "name": "force", + "allowNo": false, + "type": "boolean" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "agent:delete", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "agent", + "delete.js" + ] + }, + "agent:list": { + "aliases": [], + "args": {}, + "description": "List your agents", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --limit 10" + ], + "flags": { + "limit": { + "char": "l", + "description": "Maximum number of results", + "name": "limit", + "default": 20, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "offset": { + "char": "o", + "description": "Offset for pagination", + "name": "offset", + "default": 0, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "agent:list", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "agent", + "list.js" + ] + }, + "agent:update": { + "aliases": [], + "args": { + "id": { + "description": "Agent ID or UID", + "name": "id", + "required": true + } + }, + "description": "Update an agent", + "examples": [ + "<%= config.bin %> <%= command.id %> my-agent --name \"New Name\"", + "<%= config.bin %> <%= command.id %> my-agent --temperature 0.5 --public false" + ], + "flags": { + "name": { + "char": "n", + "description": "Agent name", + "name": "name", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "uid": { + "description": "Unique identifier (URL-friendly)", + "name": "uid", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "description": { + "char": "d", + "description": "Agent description", + "name": "description", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "provider": { + "char": "p", + "description": "AI provider", + "name": "provider", + "hasDynamicHelp": false, + "multiple": false, + "options": [ + "ANTHROPIC", + "OPENAI", + "GOOGLE", + "GROQ", + "MISTRAL" + ], + "type": "option" + }, + "model": { + "char": "m", + "description": "Model ID", + "name": "model", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "system-prompt": { + "char": "s", + "description": "System prompt", + "name": "system-prompt", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "temperature": { + "char": "t", + "description": "Temperature (0-2)", + "name": "temperature", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "public": { + "description": "Make agent public", + "name": "public", + "allowNo": true, + "type": "boolean" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "agent:update", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "agent", + "update.js" + ] + }, + "auth:login": { + "aliases": [], + "args": { + "key": { + "description": "API key (alternative to --api-key flag)", + "name": "key", + "required": false + } + }, + "description": "Authenticate with TPMJS", + "examples": [ + "<%= config.bin %> <%= command.id %> --api-key tpm_xxxxx", + "<%= config.bin %> <%= command.id %> --browser" + ], + "flags": { + "api-key": { + "char": "k", + "description": "API key (or set TPMJS_API_KEY environment variable)", + "name": "api-key", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "browser": { + "char": "b", + "description": "Open browser for OAuth authentication", + "name": "browser", + "allowNo": false, + "type": "boolean" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "auth:login", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "auth", + "login.js" + ] + }, + "auth:logout": { + "aliases": [], + "args": {}, + "description": "Log out from TPMJS", + "examples": [ + "<%= config.bin %> <%= command.id %>" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "auth:logout", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "auth", + "logout.js" + ] + }, + "auth:status": { + "aliases": [], + "args": {}, + "description": "Show authentication status", + "examples": [ + "<%= config.bin %> <%= command.id %>" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "auth:status", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "auth", + "status.js" + ] + }, + "auth:whoami": { + "aliases": [], + "args": {}, + "description": "Show current user information", + "examples": [ + "<%= config.bin %> <%= command.id %>" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "auth:whoami", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "auth", + "whoami.js" + ] + }, + "collection:add": { + "aliases": [], + "args": { + "collection": { + "description": "Collection ID or slug", + "name": "collection", + "required": true + } + }, + "description": "Add tools to a collection", + "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" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "collection:add", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": false, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "collection", + "add.js" + ] + }, + "collection:create": { + "aliases": [], + "args": {}, + "description": "Create a new collection", + "examples": [ + "<%= config.bin %> <%= command.id %> --name \"My Tools\"", + "<%= config.bin %> <%= command.id %> --name \"Web Scrapers\" --description \"Tools for web scraping\" --public" + ], + "flags": { + "name": { + "char": "n", + "description": "Collection name", + "name": "name", + "required": true, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "description": { + "char": "d", + "description": "Collection description", + "name": "description", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "public": { + "description": "Make collection public", + "name": "public", + "allowNo": false, + "type": "boolean" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "collection:create", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "collection", + "create.js" + ] + }, + "collection:delete": { + "aliases": [], + "args": { + "id": { + "description": "Collection ID or slug", + "name": "id", + "required": true + } + }, + "description": "Delete a collection", + "examples": [ + "<%= config.bin %> <%= command.id %> my-collection", + "<%= config.bin %> <%= command.id %> my-collection --force" + ], + "flags": { + "force": { + "char": "f", + "description": "Skip confirmation prompt", + "name": "force", + "allowNo": false, + "type": "boolean" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "collection:delete", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "collection", + "delete.js" + ] + }, + "collection:import": { + "aliases": [], + "args": { + "collection": { + "description": "Collection ID or slug", + "name": "collection", + "required": true + } + }, + "description": "Import tools to a collection from a file", + "examples": [ + "<%= config.bin %> <%= command.id %> my-collection --file tools.txt", + "<%= config.bin %> <%= command.id %> my-collection --file tools.json" + ], + "flags": { + "file": { + "char": "f", + "description": "File containing tool IDs (one per line or JSON array)", + "name": "file", + "required": true, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "collection:import", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "collection", + "import.js" + ] + }, + "collection:list": { + "aliases": [], + "args": {}, + "description": "List your collections", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --limit 10" + ], + "flags": { + "limit": { + "char": "l", + "description": "Maximum number of results", + "name": "limit", + "default": 20, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "offset": { + "char": "o", + "description": "Offset for pagination", + "name": "offset", + "default": 0, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "collection:list", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "collection", + "list.js" + ] + }, + "collection:remove": { + "aliases": [], + "args": { + "collection": { + "description": "Collection ID or slug", + "name": "collection", + "required": true + }, + "tool": { + "description": "Tool ID to remove", + "name": "tool", + "required": true + } + }, + "description": "Remove a tool from a collection", + "examples": [ + "<%= config.bin %> <%= command.id %> my-collection tool-id-1" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "collection:remove", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "collection", + "remove.js" + ] + }, + "collection:update": { + "aliases": [], + "args": { + "id": { + "description": "Collection ID or slug", + "name": "id", + "required": true + } + }, + "description": "Update a collection", + "examples": [ + "<%= config.bin %> <%= command.id %> my-collection --name \"New Name\"", + "<%= config.bin %> <%= command.id %> my-collection --public" + ], + "flags": { + "name": { + "char": "n", + "description": "Collection name", + "name": "name", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "description": { + "char": "d", + "description": "Collection description", + "name": "description", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "public": { + "description": "Make collection public", + "name": "public", + "allowNo": true, + "type": "boolean" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "collection:update", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "collection", + "update.js" + ] + }, + "mcp:config": { + "aliases": [], + "args": { + "collection": { + "description": "Collection path (username/slug)", + "name": "collection", + "required": true + } + }, + "description": "Generate MCP configuration for AI clients", + "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" + ], + "flags": { + "client": { + "char": "c", + "description": "Target client (claude, cursor, windsurf, generic)", + "name": "client", + "default": "claude", + "hasDynamicHelp": false, + "multiple": false, + "options": [ + "claude", + "cursor", + "windsurf", + "generic" + ], + "type": "option" + }, + "output": { + "char": "o", + "description": "Output file path (will merge with existing config)", + "name": "output", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "api-key": { + "char": "k", + "description": "API key to include in config (optional)", + "name": "api-key", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "mcp:config", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "mcp", + "config.js" + ] + }, + "mcp:serve": { + "aliases": [], + "args": {}, + "description": "Run as a local MCP server", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --port 8080", + "<%= config.bin %> <%= command.id %> --stdio", + "<%= config.bin %> <%= command.id %> --collection my-collection" + ], + "flags": { + "port": { + "char": "p", + "description": "Port to run the server on (HTTP mode)", + "name": "port", + "default": 3333, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "stdio": { + "description": "Use stdio transport instead of HTTP", + "name": "stdio", + "allowNo": false, + "type": "boolean" + }, + "collection": { + "char": "c", + "description": "Serve tools from a specific collection", + "name": "collection", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "tool": { + "char": "t", + "description": "Serve specific tools (comma-separated)", + "name": "tool", + "hasDynamicHelp": false, + "multiple": true, + "type": "option" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "mcp:serve", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "mcp", + "serve.js" + ] + }, + "publish:check": { + "aliases": [], + "args": { + "package": { + "description": "npm package name (defaults to current directory)", + "name": "package", + "required": false + } + }, + "description": "Check if your package has been discovered by tpmjs.com", + "examples": [ + "<%= config.bin %> <%= command.id %> @myorg/my-tool", + "<%= config.bin %> <%= command.id %>" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "publish:check", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "publish", + "check.js" + ] + }, + "publish:preview": { + "aliases": [], + "args": {}, + "description": "Preview how your tool will appear on tpmjs.com", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --path ./my-tool" + ], + "flags": { + "path": { + "char": "p", + "description": "Path to package directory", + "name": "path", + "default": ".", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "publish:preview", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "publish", + "preview.js" + ] + }, + "tool:execute": { + "aliases": [], + "args": { + "tool": { + "description": "Tool slug or ID", + "name": "tool", + "required": true + } + }, + "description": "Execute a TPMJS tool", + "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" + ], + "flags": { + "input": { + "char": "i", + "description": "Input parameters as JSON string", + "name": "input", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "input-file": { + "char": "f", + "description": "Path to JSON file containing input parameters", + "name": "input-file", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "stream": { + "char": "s", + "description": "Stream output (for tools that support it)", + "name": "stream", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "char": "t", + "description": "Timeout in seconds", + "name": "timeout", + "default": 300, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "tool:execute", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "tool", + "execute.js" + ] + }, + "tool:info": { + "aliases": [], + "args": { + "package": { + "description": "Package name (e.g., @tpmjs/official-firecrawl)", + "name": "package", + "required": true + }, + "tool": { + "description": "Tool name (e.g., scrapeTool)", + "name": "tool", + "required": true + } + }, + "description": "Get detailed information about a tool", + "examples": [ + "<%= config.bin %> <%= command.id %> @tpmjs/official-firecrawl scrapeTool", + "<%= config.bin %> <%= command.id %> firecrawl-tool default" + ], + "flags": { + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "tool:info", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "tool", + "info.js" + ] + }, + "tool:init": { + "aliases": [], + "args": { + "name": { + "description": "Tool name (creates directory if not exists)", + "name": "name", + "required": false + } + }, + "description": "Initialize a new TPMJS tool package", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> my-tool", + "<%= config.bin %> <%= command.id %> --template minimal" + ], + "flags": { + "template": { + "char": "t", + "description": "Template to use", + "name": "template", + "default": "minimal", + "hasDynamicHelp": false, + "multiple": false, + "options": [ + "minimal", + "rich" + ], + "type": "option" + }, + "category": { + "char": "c", + "description": "Tool category", + "name": "category", + "hasDynamicHelp": false, + "multiple": false, + "options": [ + "research", + "web", + "data", + "documentation", + "engineering", + "security", + "statistics", + "ops", + "agent", + "sandbox", + "utilities", + "html", + "compliance" + ], + "type": "option" + }, + "force": { + "char": "f", + "description": "Overwrite existing files", + "name": "force", + "allowNo": false, + "type": "boolean" + }, + "yes": { + "char": "y", + "description": "Skip prompts and use defaults", + "name": "yes", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "tool:init", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "tool", + "init.js" + ] + }, + "tool:search": { + "aliases": [], + "args": { + "query": { + "description": "Search query", + "name": "query", + "required": false + } + }, + "description": "Search for tools in the TPMJS registry", + "examples": [ + "<%= config.bin %> <%= command.id %> firecrawl", + "<%= config.bin %> <%= command.id %> \"web scraper\" --category web", + "<%= config.bin %> <%= command.id %> --category data --limit 20" + ], + "flags": { + "category": { + "char": "c", + "description": "Filter by category", + "name": "category", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "limit": { + "char": "l", + "description": "Maximum number of results", + "name": "limit", + "default": 20, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "offset": { + "char": "o", + "description": "Offset for pagination", + "name": "offset", + "default": 0, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "tool:search", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "tool", + "search.js" + ] + }, + "tool:trending": { + "aliases": [], + "args": {}, + "description": "Show trending tools", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --limit 10" + ], + "flags": { + "limit": { + "char": "l", + "description": "Maximum number of results", + "name": "limit", + "default": 10, + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "tool:trending", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "tool", + "trending.js" + ] + }, + "tool:validate": { + "aliases": [], + "args": {}, + "description": "Validate a tpmjs package configuration", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --path ./my-tool" + ], + "flags": { + "path": { + "char": "p", + "description": "Path to package directory (defaults to current directory)", + "name": "path", + "default": ".", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "json": { + "description": "Output in JSON format", + "name": "json", + "allowNo": false, + "type": "boolean" + }, + "verbose": { + "char": "v", + "description": "Show verbose output", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "tool:validate", + "pluginAlias": "@tpmjs/cli", + "pluginName": "@tpmjs/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "tool", + "validate.js" + ] + } + }, + "version": "0.1.2" +} \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..17338a6 --- /dev/null +++ b/packages/cli/package.json @@ -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" + } +} diff --git a/packages/cli/src/commands/agent/chat.ts b/packages/cli/src/commands/agent/chat.ts new file mode 100644 index 0000000..c806dbd --- /dev/null +++ b/packages/cli/src/commands/agent/chat.ts @@ -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 { + 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 + ): Promise { + 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 + ): Promise { + 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; + } +} diff --git a/packages/cli/src/commands/agent/create.ts b/packages/cli/src/commands/agent/create.ts new file mode 100644 index 0000000..e9d7378 --- /dev/null +++ b/packages/cli/src/commands/agent/create.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts new file mode 100644 index 0000000..83e5e6a --- /dev/null +++ b/packages/cli/src/commands/agent/delete.ts @@ -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 { + 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 { + 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'); + }); + }); + } +} diff --git a/packages/cli/src/commands/agent/list.ts b/packages/cli/src/commands/agent/list.ts new file mode 100644 index 0000000..e0ffedd --- /dev/null +++ b/packages/cli/src/commands/agent/list.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/agent/update.ts b/packages/cli/src/commands/agent/update.ts new file mode 100644 index 0000000..96ae470 --- /dev/null +++ b/packages/cli/src/commands/agent/update.ts @@ -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 { + 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 = {}; + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts new file mode 100644 index 0000000..3cc490f --- /dev/null +++ b/packages/cli/src/commands/auth/login.ts @@ -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 { + 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 '); + 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, + flags: { json?: boolean; verbose?: boolean } + ): Promise { + 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, + flags: { json?: boolean; verbose?: boolean } + ): Promise { + 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('

Authentication Failed

You can close this window.

'); + server.close(); + output.error(`Authentication failed: ${error}`); + resolve(); + return; + } + + if (receivedState !== state) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Invalid State

Authentication failed due to invalid state.

'); + server.close(); + output.error('Authentication failed: Invalid state parameter'); + resolve(); + return; + } + + if (apiKey) { + saveCredentials({ apiKey }); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Success!

You are now logged in. You can close this window.

'); + 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('

Error

No API key received.

'); + 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); + }); + } +} diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts new file mode 100644 index 0000000..8aa2b35 --- /dev/null +++ b/packages/cli/src/commands/auth/logout.ts @@ -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 { + 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'); + } + } +} diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts new file mode 100644 index 0000000..ad021cd --- /dev/null +++ b/packages/cli/src/commands/auth/status.ts @@ -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 { + 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 + ); + } + } + } +} diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts new file mode 100644 index 0000000..c542c5b --- /dev/null +++ b/packages/cli/src/commands/auth/whoami.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/collection/add.ts b/packages/cli/src/commands/collection/add.ts new file mode 100644 index 0000000..3bf61e3 --- /dev/null +++ b/packages/cli/src/commands/collection/add.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/collection/create.ts b/packages/cli/src/commands/collection/create.ts new file mode 100644 index 0000000..3e8984d --- /dev/null +++ b/packages/cli/src/commands/collection/create.ts @@ -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 { + 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} `); + } catch (error) { + spinner.fail('Failed to create collection'); + output.error( + error instanceof Error ? error.message : 'Unknown error', + flags.verbose ? String(error) : undefined + ); + } + } +} diff --git a/packages/cli/src/commands/collection/delete.ts b/packages/cli/src/commands/collection/delete.ts new file mode 100644 index 0000000..3892f59 --- /dev/null +++ b/packages/cli/src/commands/collection/delete.ts @@ -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 { + 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 { + 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'); + }); + }); + } +} diff --git a/packages/cli/src/commands/collection/import.ts b/packages/cli/src/commands/collection/import.ts new file mode 100644 index 0000000..8bbee29 --- /dev/null +++ b/packages/cli/src/commands/collection/import.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/collection/list.ts b/packages/cli/src/commands/collection/list.ts new file mode 100644 index 0000000..d1f6144 --- /dev/null +++ b/packages/cli/src/commands/collection/list.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/collection/remove.ts b/packages/cli/src/commands/collection/remove.ts new file mode 100644 index 0000000..0b2a546 --- /dev/null +++ b/packages/cli/src/commands/collection/remove.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/collection/update.ts b/packages/cli/src/commands/collection/update.ts new file mode 100644 index 0000000..0b64c97 --- /dev/null +++ b/packages/cli/src/commands/collection/update.ts @@ -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 { + 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 = {}; + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..05dc827 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -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 { + 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'); + } + } +} diff --git a/packages/cli/src/commands/mcp/config.ts b/packages/cli/src/commands/mcp/config.ts new file mode 100644 index 0000000..7ce4781 --- /dev/null +++ b/packages/cli/src/commands/mcp/config.ts @@ -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 { + 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 = {}; + 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 + : {}; + const mergedConfig = { + ...existingConfig, + mcpServers: { + ...existingServers, + ...(config.mcpServers as Record), + }, + }; + + 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 { + 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'; + } +} diff --git a/packages/cli/src/commands/mcp/serve.ts b/packages/cli/src/commands/mcp/serve.ts new file mode 100644 index 0000000..b42e42e --- /dev/null +++ b/packages/cli/src/commands/mcp/serve.ts @@ -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; +} + +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 = new Map(); + + async run(): Promise { + 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 + ): Promise { + 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, + verbose: boolean + ): Promise { + 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, + verbose: boolean + ): Promise { + 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 { + 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).description || '', + inputSchema: (tool as Record).inputSchema || { + type: 'object', + properties: {}, + }, + })), + }, + }; + + case 'tools/call': { + const toolName = (params as Record)?.name as string; + const toolArgs = (params as Record)?.arguments as Record; + + 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}`, + }, + }; + } + } +} diff --git a/packages/cli/src/commands/playground.ts b/packages/cli/src/commands/playground.ts new file mode 100644 index 0000000..e1f91e8 --- /dev/null +++ b/packages/cli/src/commands/playground.ts @@ -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 { + 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, verbose: boolean): Promise { + 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((resolve) => { + this.rl?.on('close', resolve); + }); + } + + private async handleCommand( + command: string, + output: ReturnType, + verbose: boolean + ): Promise { + 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 - 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 '); + } 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): Promise { + 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 to select a tool'); + } catch { + spinner.fail('Failed to load tools'); + } + } + + private async showToolInfo( + output: ReturnType, + verbose: boolean + ): Promise { + 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, + output: ReturnType, + verbose: boolean + ): Promise { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/publish/check.ts b/packages/cli/src/commands/publish/check.ts new file mode 100644 index 0000000..10e5403 --- /dev/null +++ b/packages/cli/src/commands/publish/check.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/publish/preview.ts b/packages/cli/src/commands/publish/preview.ts new file mode 100644 index 0000000..95a6084 --- /dev/null +++ b/packages/cli/src/commands/publish/preview.ts @@ -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 { + 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 { + // 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 | undefined { + const author = packageJson.author; + if (typeof author === 'string') { + return author; + } + if (author && typeof author === 'object') { + const authorObj = author as Record; + return authorObj.name || authorObj.email; + } + return undefined; + } + + private extractRepo(packageJson: Record): string | undefined { + const repo = packageJson.repository; + if (typeof repo === 'string') { + return repo; + } + if (repo && typeof repo === 'object') { + return (repo as Record).url; + } + return undefined; + } +} diff --git a/packages/cli/src/commands/tool/execute.ts b/packages/cli/src/commands/tool/execute.ts new file mode 100644 index 0000000..5807459 --- /dev/null +++ b/packages/cli/src/commands/tool/execute.ts @@ -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 { + const { args, flags } = await this.parse(ToolExecute); + const output = createOutput(flags); + const client = getClient(); + + // Parse input parameters + let params: Record = {}; + + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/tool/info.ts b/packages/cli/src/commands/tool/info.ts new file mode 100644 index 0000000..a4a876b --- /dev/null +++ b/packages/cli/src/commands/tool/info.ts @@ -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 { + 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'; + } +} diff --git a/packages/cli/src/commands/tool/init.ts b/packages/cli/src/commands/tool/init.ts new file mode 100644 index 0000000..e43ed59 --- /dev/null +++ b/packages/cli/src/commands/tool/init.ts @@ -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 { + 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 + ): 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 { + 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 + ): Promise { + 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'); + } +} diff --git a/packages/cli/src/commands/tool/search.ts b/packages/cli/src/commands/tool/search.ts new file mode 100644 index 0000000..33f1b9a --- /dev/null +++ b/packages/cli/src/commands/tool/search.ts @@ -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 { + 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'; +} diff --git a/packages/cli/src/commands/tool/trending.ts b/packages/cli/src/commands/tool/trending.ts new file mode 100644 index 0000000..1dec0f6 --- /dev/null +++ b/packages/cli/src/commands/tool/trending.ts @@ -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 { + 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(); +} diff --git a/packages/cli/src/commands/tool/validate.ts b/packages/cli/src/commands/tool/validate.ts new file mode 100644 index 0000000..b28ee02 --- /dev/null +++ b/packages/cli/src/commands/tool/validate.ts @@ -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 { + 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; + 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 + ); + } + } +} diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000..6e8304f --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -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 { + 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 + ); + } + } +} diff --git a/packages/cli/src/hooks/init.ts b/packages/cli/src/hooks/init.ts new file mode 100644 index 0000000..cfaa7fd --- /dev/null +++ b/packages/cli/src/hooks/init.ts @@ -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; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..268958e --- /dev/null +++ b/packages/cli/src/index.ts @@ -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'; diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts new file mode 100644 index 0000000..97f9421 --- /dev/null +++ b/packages/cli/src/lib/api-client.ts @@ -0,0 +1,470 @@ +import { getApiKey, getApiUrl } from './config.js'; + +export interface TpmClientOptions { + baseUrl?: string; + apiKey?: string; + timeout?: number; +} + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface PaginationOptions { + limit?: number; + offset?: number; +} + +export interface PaginatedResponse { + 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; + outputSchema?: Record; + 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( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + 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> { + return this.request('/stats'); + } + + // Tools + async searchTools(options: ToolSearchOptions = {}): Promise> { + 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>(endpoint); + } + + async getTool(packageName: string, toolName: string): Promise> { + return this.request(`/tools/${encodeURIComponent(packageName)}/${encodeURIComponent(toolName)}`); + } + + async getToolBySlug(slug: string): Promise> { + // 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> { + 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>(endpoint); + } + + async validateTpmjsField(field: unknown): Promise> { + return this.request('/tools/validate', { + method: 'POST', + body: JSON.stringify(field), + }); + } + + async executeTool(slug: string, params: Record): Promise { + return this.request(`/tools/${encodeURIComponent(slug)}/execute`, { + method: 'POST', + body: JSON.stringify(params), + }); + } + + async *executeToolStream( + slug: string, + params: Record + ): AsyncGenerator<{ type: string; data: string }> { + const url = `${this.baseUrl}/tools/${encodeURIComponent(slug)}/execute`; + const headers: Record = { + '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> { + 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>(endpoint); + } + + async getAgent(id: string): Promise> { + return this.request(`/agents/${id}`); + } + + async createAgent(input: CreateAgentInput): Promise> { + return this.request('/agents', { + method: 'POST', + body: JSON.stringify(input), + }); + } + + async updateAgent(id: string, input: UpdateAgentInput): Promise> { + return this.request(`/agents/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }); + } + + async deleteAgent(id: string): Promise> { + return this.request(`/agents/${id}`, { + method: 'DELETE', + }); + } + + // Collections + async listCollections(options: PaginationOptions = {}): Promise> { + 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>(endpoint); + } + + async getCollection(id: string): Promise> { + return this.request(`/collections/${id}`); + } + + async createCollection(input: CreateCollectionInput): Promise> { + return this.request('/collections', { + method: 'POST', + body: JSON.stringify(input), + }); + } + + async updateCollection(id: string, input: UpdateCollectionInput): Promise> { + return this.request(`/collections/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }); + } + + async deleteCollection(id: string): Promise> { + return this.request(`/collections/${id}`, { + method: 'DELETE', + }); + } + + async addToolsToCollection(id: string, toolIds: string[]): Promise> { + // 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> { + return this.request(`/collections/${id}/tools/${toolId}`, { + method: 'DELETE', + }); + } + + // User + async whoami(): Promise> { + return this.request('/user/profile'); + } + + async listApiKeys(): Promise> { + 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; +} diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts new file mode 100644 index 0000000..ed8b6f3 --- /dev/null +++ b/packages/cli/src/lib/config.ts @@ -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; +} + +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({ + 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): void { + for (const [key, value] of Object.entries(config)) { + if (value !== undefined) { + configStore.set(key, value); + } + } +} + +export function getConfigValue(key: K): TpmConfig[K] { + return configStore.get(key); +} + +export function setConfigValue( + 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; +} diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts new file mode 100644 index 0000000..2fb9be9 --- /dev/null +++ b/packages/cli/src/lib/output.ts @@ -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>( + 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, + }); +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..6521d56 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@tpmjs/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "incremental": false, + "composite": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..3c8f1e7 --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -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, +});