feat(cli): add @tpmjs/cli package and browser auth page

- Create comprehensive CLI with oclif framework
- Add commands: auth, tool, agent, collection, mcp, publish
- Add doctor, playground, and update commands
- Add /cli/auth page for browser-based OAuth flow
- Publish to npm as @tpmjs/cli@0.1.2
This commit is contained in:
Ajax Davis 2026-01-18 06:12:34 +10:00
parent aa438078f9
commit 154f000505
41 changed files with 7046 additions and 0 deletions

127
packages/cli/README.md Normal file
View file

@ -0,0 +1,127 @@
# @tpmjs/cli
Command-line interface for TPMJS - the universal tool registry for AI agents.
## Installation
```bash
npm install -g @tpmjs/cli
```
## Quick Start
```bash
# Search for tools
tpm tool search firecrawl
# Get tool info
tpm tool info @tpmjs/official-firecrawl scrapeTool
# Show trending tools
tpm tool trending
# Authenticate
tpm auth login --api-key YOUR_API_KEY
# List your agents
tpm agent list
# List your collections
tpm collection list
# Generate MCP config
tpm mcp config ajax/ajax-collection
```
## Commands
### Authentication
```bash
tpm auth login # Login with API key or browser OAuth
tpm auth logout # Log out
tpm auth status # Show authentication status
tpm auth whoami # Show current user info
```
### Tools
```bash
tpm tool search [query] # Search for tools
tpm tool info <pkg> <tool> # Get tool details
tpm tool trending # Show trending tools
tpm tool validate # Validate local tpmjs config
```
### Agents
```bash
tpm agent list # List your agents
tpm agent create # Create a new agent
tpm agent update <id> # Update an agent
tpm agent delete <id> # Delete an agent
tpm agent chat <id> # Chat with an agent
```
### Collections
```bash
tpm collection list # List your collections
tpm collection create # Create a collection
tpm collection add # Add tools to a collection
tpm collection remove # Remove tools from a collection
```
### MCP Integration
```bash
tpm mcp config <user/collection> # Generate MCP config
tpm mcp serve # Run as local MCP server
```
### Utilities
```bash
tpm doctor # Run diagnostic checks
tpm update # Update CLI to latest version
```
## Configuration
Config is stored in `~/.tpmjs/config.json`:
```json
{
"apiUrl": "https://tpmjs.com/api",
"defaultOutput": "human",
"verbose": false,
"analytics": false
}
```
Credentials are stored securely in `~/.tpmjs/credentials.json`.
## Environment Variables
- `TPMJS_API_KEY` - API key for authentication
- `TPMJS_API_URL` - Custom API URL (default: https://tpmjs.com/api)
## Output Formats
All commands support `--json` flag for machine-readable output:
```bash
tpm tool search firecrawl --json | jq '.data[0].name'
```
## Verbose Mode
Use `--verbose` or `-v` for detailed output:
```bash
tpm doctor --verbose
```
## License
MIT

5
packages/cli/bin/run.js Normal file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env node
import { execute } from '@oclif/core';
await execute({ dir: import.meta.url });

File diff suppressed because it is too large Load diff

103
packages/cli/package.json Normal file
View file

@ -0,0 +1,103 @@
{
"name": "@tpmjs/cli",
"version": "0.1.2",
"description": "TPMJS command-line interface for AI tool discovery and execution",
"author": "TPMJS",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tpmjs/tpmjs.git",
"directory": "packages/cli"
},
"homepage": "https://tpmjs.com",
"keywords": [
"tpmjs",
"cli",
"mcp",
"ai",
"tools",
"agents"
],
"type": "module",
"bin": {
"tpm": "./bin/run.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"bin",
"oclif.manifest.json"
],
"oclif": {
"bin": "tpm",
"dirname": "tpmjs",
"commands": "./dist/commands",
"plugins": [
"@oclif/plugin-help",
"@oclif/plugin-plugins",
"@oclif/plugin-autocomplete"
],
"hooks": {
"init": "./dist/hooks/init"
},
"topics": {
"tool": {
"description": "Tool discovery and execution"
},
"agent": {
"description": "AI agent management"
},
"collection": {
"description": "Collection management"
},
"auth": {
"description": "Authentication"
},
"mcp": {
"description": "MCP integration"
},
"publish": {
"description": "Publishing workflow"
}
},
"topicSeparator": " "
},
"scripts": {
"build": "tsup && oclif manifest",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist .turbo oclif.manifest.json",
"postpack": "rm -f oclif.manifest.json",
"prepack": "pnpm build"
},
"dependencies": {
"@oclif/core": "^4.2.10",
"@oclif/plugin-help": "^6.2.27",
"@oclif/plugin-plugins": "^5.4.36",
"@oclif/plugin-autocomplete": "^3.2.25",
"conf": "^13.1.0",
"open": "^10.1.0",
"picocolors": "^1.1.1",
"ora": "^8.2.0",
"eventsource-parser": "^3.0.1",
"cli-table3": "^0.6.5"
},
"devDependencies": {
"@tpmjs/tsconfig": "workspace:*",
"@types/node": "^22.15.29",
"oclif": "^4.17.35",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,216 @@
import { Args, Command, Flags } from '@oclif/core';
import * as readline from 'node:readline';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
import { getApiKey, getApiUrl } from '../../lib/config.js';
export default class AgentChat extends Command {
static description = 'Chat with an agent';
static examples = [
'<%= config.bin %> <%= command.id %> my-agent "Hello!"',
'<%= config.bin %> <%= command.id %> my-agent --interactive',
'<%= config.bin %> <%= command.id %> my-agent -i',
];
static flags = {
interactive: Flags.boolean({
char: 'i',
description: 'Enter interactive chat mode (REPL)',
default: false,
}),
conversation: Flags.string({
char: 'c',
description: 'Continue existing conversation by ID',
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
agent: Args.string({
description: 'Agent ID or UID',
required: true,
}),
message: Args.string({
description: 'Message to send (required unless --interactive)',
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(AgentChat);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
// Verify agent exists
const agentResponse = await client.getAgent(args.agent);
if (!agentResponse.success || !agentResponse.data) {
output.error('Agent not found');
return;
}
const agent = agentResponse.data;
if (flags.interactive) {
await this.interactiveChat(agent, flags, output);
} else if (args.message) {
await this.singleMessage(agent, args.message, flags, output);
} else {
output.error('Please provide a message or use --interactive flag');
output.text('Examples:');
output.listItem(`tpm agent chat ${args.agent} "Hello!"`);
output.listItem(`tpm agent chat ${args.agent} --interactive`);
}
}
private async singleMessage(
agent: { id: string; name: string },
message: string,
flags: { json?: boolean; verbose?: boolean; conversation?: string },
output: ReturnType<typeof createOutput>
): Promise<void> {
const spinner = output.spinner('Sending message...');
try {
const response = await this.sendChatMessage(agent.id, message, flags.conversation);
spinner.stop();
if (flags.json) {
output.json(response);
return;
}
if (response.content) {
output.text(response.content);
}
if (response.toolCalls && response.toolCalls.length > 0) {
output.newLine();
output.subheading('Tool Calls:');
for (const call of response.toolCalls) {
output.listItem(`${call.name}: ${JSON.stringify(call.result)}`);
}
}
} catch (error) {
spinner.fail('Failed to send message');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
private async interactiveChat(
agent: { id: string; name: string; uid: string },
flags: { json?: boolean; verbose?: boolean; conversation?: string },
output: ReturnType<typeof createOutput>
): Promise<void> {
output.heading(`Chat with ${agent.name}`);
output.text(output.dim('Type "exit" or Ctrl+C to quit'));
output.hr();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const conversationId = flags.conversation;
const prompt = () => {
rl.question('\n> ', async (message) => {
if (message.toLowerCase() === 'exit' || message.toLowerCase() === 'quit') {
output.info('Goodbye!');
rl.close();
return;
}
if (!message.trim()) {
prompt();
return;
}
try {
process.stdout.write('\n');
const response = await this.sendChatMessage(agent.id, message, conversationId);
if (response.content) {
output.text(response.content);
}
if (response.toolCalls && response.toolCalls.length > 0) {
output.newLine();
output.text(output.dim('Tool calls:'));
for (const call of response.toolCalls) {
output.text(output.dim(`${call.name}`));
}
}
} catch (error) {
output.error(error instanceof Error ? error.message : 'Failed to send message');
}
prompt();
});
};
prompt();
// Handle Ctrl+C
rl.on('SIGINT', () => {
output.newLine();
output.info('Goodbye!');
rl.close();
process.exit(0);
});
}
private async sendChatMessage(
agentId: string,
message: string,
conversationId?: string
): Promise<{
content: string;
conversationId: string;
toolCalls?: { name: string; result: unknown }[];
}> {
const apiKey = getApiKey();
const apiUrl = getApiUrl();
const url = `${apiUrl}/agents/${agentId}/chat`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
},
body: JSON.stringify({
message,
conversationId,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { message?: string };
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const data = await response.json() as {
content: string;
conversationId: string;
toolCalls?: { name: string; result: unknown }[];
};
return data;
}
}

View file

@ -0,0 +1,114 @@
import { Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class AgentCreate extends Command {
static description = 'Create a new agent';
static examples = [
'<%= config.bin %> <%= command.id %> --name "My Agent" --provider ANTHROPIC --model claude-3-5-sonnet-20241022',
'<%= config.bin %> <%= command.id %> --name "GPT Agent" --provider OPENAI --model gpt-4o --public',
];
static flags = {
name: Flags.string({
char: 'n',
description: 'Agent name',
required: true,
}),
uid: Flags.string({
description: 'Unique identifier (URL-friendly)',
}),
description: Flags.string({
char: 'd',
description: 'Agent description',
}),
provider: Flags.string({
char: 'p',
description: 'AI provider (ANTHROPIC, OPENAI, GOOGLE, GROQ, MISTRAL)',
required: true,
options: ['ANTHROPIC', 'OPENAI', 'GOOGLE', 'GROQ', 'MISTRAL'],
}),
model: Flags.string({
char: 'm',
description: 'Model ID',
required: true,
}),
'system-prompt': Flags.string({
char: 's',
description: 'System prompt',
}),
temperature: Flags.string({
char: 't',
description: 'Temperature (0-2)',
default: '0.7',
}),
public: Flags.boolean({
description: 'Make agent public',
default: true,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(AgentCreate);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
const spinner = output.spinner('Creating agent...');
try {
const response = await client.createAgent({
name: flags.name,
uid: flags.uid,
description: flags.description,
provider: flags.provider as 'ANTHROPIC' | 'OPENAI' | 'GOOGLE' | 'GROQ' | 'MISTRAL',
modelId: flags.model,
systemPrompt: flags['system-prompt'],
temperature: parseFloat(flags.temperature),
isPublic: flags.public,
});
spinner.stop();
if (!response.success || !response.data) {
output.error(response.message || 'Failed to create agent');
return;
}
if (flags.json) {
output.json(response.data);
return;
}
output.success(`Agent "${response.data.name}" created successfully`);
output.newLine();
output.keyValue('ID', response.data.id);
output.keyValue('UID', response.data.uid);
output.keyValue('Provider', response.data.provider);
output.keyValue('Model', response.data.modelId);
output.keyValue('Public', response.data.isPublic ? 'Yes' : 'No');
output.newLine();
output.text(`Chat with it: tpm agent chat ${response.data.uid}`);
} catch (error) {
spinner.fail('Failed to create agent');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,103 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
import * as readline from 'node:readline';
export default class AgentDelete extends Command {
static description = 'Delete an agent';
static examples = [
'<%= config.bin %> <%= command.id %> my-agent',
'<%= config.bin %> <%= command.id %> my-agent --force',
];
static flags = {
force: Flags.boolean({
char: 'f',
description: 'Skip confirmation prompt',
default: false,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
id: Args.string({
description: 'Agent ID or UID',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(AgentDelete);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
// Get agent info first
const agentResponse = await client.getAgent(args.id);
if (!agentResponse.success || !agentResponse.data) {
output.error('Agent not found');
return;
}
const agent = agentResponse.data;
// Confirm deletion
if (!flags.force) {
const confirmed = await this.confirm(
`Are you sure you want to delete agent "${agent.name}"? This cannot be undone.`
);
if (!confirmed) {
output.info('Deletion cancelled');
return;
}
}
const spinner = output.spinner('Deleting agent...');
try {
await client.deleteAgent(args.id);
spinner.stop();
if (flags.json) {
output.json({ success: true, deleted: args.id });
return;
}
output.success(`Agent "${agent.name}" deleted successfully`);
} catch (error) {
spinner.fail('Failed to delete agent');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
private async confirm(message: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${message} [y/N] `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}
}

View file

@ -0,0 +1,102 @@
import { Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class AgentList extends Command {
static description = 'List your agents';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --limit 10',
];
static flags = {
limit: Flags.integer({
char: 'l',
description: 'Maximum number of results',
default: 20,
}),
offset: Flags.integer({
char: 'o',
description: 'Offset for pagination',
default: 0,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(AgentList);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
const spinner = output.spinner('Fetching agents...');
try {
const response = await client.listAgents({
limit: flags.limit,
offset: flags.offset,
});
spinner.stop();
if (flags.json) {
output.json(response);
return;
}
if (response.data.length === 0) {
output.info('No agents found');
output.text('Create one with: tpm agent create');
return;
}
output.table(
response.data.map((agent) => ({
uid: agent.uid,
name: agent.name,
provider: agent.provider,
model: agent.modelId,
public: agent.isPublic ? 'Yes' : 'No',
tools: agent._count?.tools ?? 0,
collections: agent._count?.collections ?? 0,
})),
[
{ key: 'uid', header: 'UID', width: 20 },
{ key: 'name', header: 'Name', width: 25 },
{ key: 'provider', header: 'Provider', width: 12 },
{ key: 'model', header: 'Model', width: 20 },
{ key: 'public', header: 'Public', width: 8 },
{ key: 'tools', header: 'Tools', width: 7 },
{ key: 'collections', header: 'Collections', width: 12 },
]
);
output.newLine();
output.text(
output.dim(
`Showing ${response.data.length} agent(s)` +
(response.pagination.hasMore ? ` (more available)` : '')
)
);
} catch (error) {
spinner.fail('Failed to fetch agents');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,116 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class AgentUpdate extends Command {
static description = 'Update an agent';
static examples = [
'<%= config.bin %> <%= command.id %> my-agent --name "New Name"',
'<%= config.bin %> <%= command.id %> my-agent --temperature 0.5 --public false',
];
static flags = {
name: Flags.string({
char: 'n',
description: 'Agent name',
}),
uid: Flags.string({
description: 'Unique identifier (URL-friendly)',
}),
description: Flags.string({
char: 'd',
description: 'Agent description',
}),
provider: Flags.string({
char: 'p',
description: 'AI provider',
options: ['ANTHROPIC', 'OPENAI', 'GOOGLE', 'GROQ', 'MISTRAL'],
}),
model: Flags.string({
char: 'm',
description: 'Model ID',
}),
'system-prompt': Flags.string({
char: 's',
description: 'System prompt',
}),
temperature: Flags.string({
char: 't',
description: 'Temperature (0-2)',
}),
public: Flags.boolean({
description: 'Make agent public',
allowNo: true,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
id: Args.string({
description: 'Agent ID or UID',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(AgentUpdate);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
// Build update payload
const updates: Record<string, unknown> = {};
if (flags.name) updates.name = flags.name;
if (flags.uid) updates.uid = flags.uid;
if (flags.description !== undefined) updates.description = flags.description;
if (flags.provider) updates.provider = flags.provider;
if (flags.model) updates.modelId = flags.model;
if (flags['system-prompt'] !== undefined) updates.systemPrompt = flags['system-prompt'];
if (flags.temperature) updates.temperature = parseFloat(flags.temperature);
if (flags.public !== undefined) updates.isPublic = flags.public;
if (Object.keys(updates).length === 0) {
output.error('No updates specified. Use --help to see available options.');
return;
}
const spinner = output.spinner('Updating agent...');
try {
const response = await client.updateAgent(args.id, updates);
spinner.stop();
if (!response.success || !response.data) {
output.error(response.message || 'Failed to update agent');
return;
}
if (flags.json) {
output.json(response.data);
return;
}
output.success(`Agent "${response.data.name}" updated successfully`);
} catch (error) {
spinner.fail('Failed to update agent');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,200 @@
import { Args, Command, Flags } from '@oclif/core';
import open from 'open';
import { createServer } from 'node:http';
import { URL } from 'node:url';
import { saveCredentials, getApiUrl } from '../../lib/config.js';
import { TpmClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class Login extends Command {
static description = 'Authenticate with TPMJS';
static examples = [
'<%= config.bin %> <%= command.id %> --api-key tpm_xxxxx',
'<%= config.bin %> <%= command.id %> --browser',
];
static flags = {
'api-key': Flags.string({
char: 'k',
description: 'API key (or set TPMJS_API_KEY environment variable)',
}),
browser: Flags.boolean({
char: 'b',
description: 'Open browser for OAuth authentication',
default: false,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
key: Args.string({
description: 'API key (alternative to --api-key flag)',
required: false,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(Login);
const output = createOutput(flags);
const apiKey = flags['api-key'] ?? args.key;
if (apiKey) {
// Direct API key authentication
await this.loginWithApiKey(apiKey, output, flags);
} else if (flags.browser) {
// Browser OAuth flow
await this.loginWithBrowser(output, flags);
} else {
// Prompt for API key
output.info('No API key provided. Use --api-key or --browser flag.');
output.newLine();
output.text('Options:');
output.listItem('tpm auth login --api-key <your-api-key>');
output.listItem('tpm auth login --browser (opens browser for OAuth)');
output.newLine();
output.text(`Get your API key at: ${output.link('tpmjs.com/dashboard/settings/tpmjs-api-keys', 'https://tpmjs.com/dashboard/settings/tpmjs-api-keys')}`);
}
}
private async loginWithApiKey(
apiKey: string,
output: ReturnType<typeof createOutput>,
flags: { json?: boolean; verbose?: boolean }
): Promise<void> {
const spinner = output.spinner('Validating API key...');
try {
// Test the API key
const client = new TpmClient({ apiKey });
const response = await client.whoami();
if (!response.success || !response.data) {
spinner.fail('Invalid API key');
return;
}
// Save credentials
saveCredentials({ apiKey });
spinner.succeed('Logged in successfully');
if (flags.json) {
output.json({
success: true,
user: response.data,
});
} else {
output.newLine();
output.keyValue('Email', response.data.email);
if (response.data.username) {
output.keyValue('Username', response.data.username);
}
if (response.data.name) {
output.keyValue('Name', response.data.name);
}
}
} catch (error) {
spinner.fail('Authentication failed');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
private async loginWithBrowser(
output: ReturnType<typeof createOutput>,
flags: { json?: boolean; verbose?: boolean }
): Promise<void> {
const port = 9876;
const callbackUrl = `http://localhost:${port}/callback`;
const state = crypto.randomUUID();
output.info('Opening browser for authentication...');
return new Promise((resolve) => {
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
if (url.pathname === '/callback') {
const receivedState = url.searchParams.get('state');
const apiKey = url.searchParams.get('key');
const error = url.searchParams.get('error');
if (error) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Authentication Failed</h1><p>You can close this window.</p></body></html>');
server.close();
output.error(`Authentication failed: ${error}`);
resolve();
return;
}
if (receivedState !== state) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Invalid State</h1><p>Authentication failed due to invalid state.</p></body></html>');
server.close();
output.error('Authentication failed: Invalid state parameter');
resolve();
return;
}
if (apiKey) {
saveCredentials({ apiKey });
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Success!</h1><p>You are now logged in. You can close this window.</p></body></html>');
server.close();
output.success('Logged in successfully via browser');
if (flags.json) {
output.json({ success: true });
}
resolve();
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Error</h1><p>No API key received.</p></body></html>');
server.close();
output.error('No API key received from authentication');
resolve();
}
} else {
res.writeHead(404);
res.end('Not found');
}
});
server.listen(port, async () => {
const authUrl = `${getApiUrl().replace('/api', '')}/cli/auth?state=${state}&callback=${encodeURIComponent(callbackUrl)}`;
output.debug(`Auth URL: ${authUrl}`);
output.text('Waiting for authentication...');
try {
await open(authUrl);
} catch {
output.warning('Could not open browser automatically.');
output.text(`Please open this URL manually: ${authUrl}`);
}
});
// Timeout after 5 minutes
setTimeout(() => {
server.close();
output.error('Authentication timed out');
resolve();
}, 5 * 60 * 1000);
});
}
}

View file

@ -0,0 +1,38 @@
import { Command, Flags } from '@oclif/core';
import { deleteCredentials, hasCredentials } from '../../lib/config.js';
import { createOutput } from '../../lib/output.js';
export default class Logout extends Command {
static description = 'Log out from TPMJS';
static examples = ['<%= config.bin %> <%= command.id %>'];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Logout);
const output = createOutput(flags);
if (!hasCredentials()) {
if (flags.json) {
output.json({ success: true, message: 'Not logged in' });
} else {
output.info('Not logged in');
}
return;
}
deleteCredentials();
if (flags.json) {
output.json({ success: true, message: 'Logged out successfully' });
} else {
output.success('Logged out successfully');
}
}
}

View file

@ -0,0 +1,116 @@
import { Command, Flags } from '@oclif/core';
import { hasCredentials, getApiKey, getApiUrl } from '../../lib/config.js';
import { TpmClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class Status extends Command {
static description = 'Show authentication status';
static examples = ['<%= config.bin %> <%= command.id %>'];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Status);
const output = createOutput(flags);
const apiKey = getApiKey();
const apiUrl = getApiUrl();
const hasStoredCredentials = hasCredentials();
// Determine auth source
let authSource: 'env' | 'config' | 'none' = 'none';
if (process.env.TPMJS_API_KEY) {
authSource = 'env';
} else if (hasStoredCredentials) {
authSource = 'config';
}
if (!apiKey) {
if (flags.json) {
output.json({
authenticated: false,
authSource: null,
apiUrl,
});
} else {
output.warning('Not authenticated');
output.newLine();
output.text('Run `tpm auth login` to authenticate.');
}
return;
}
// Test the API key
const spinner = output.spinner('Checking authentication...');
try {
const client = new TpmClient({ apiKey });
const response = await client.whoami();
if (!response.success || !response.data) {
spinner.fail('API key is invalid');
if (flags.json) {
output.json({
authenticated: false,
authSource,
apiUrl,
error: 'Invalid API key',
});
}
return;
}
spinner.succeed('Authenticated');
if (flags.json) {
output.json({
authenticated: true,
authSource,
apiUrl,
user: response.data,
keyPrefix: apiKey.substring(0, 12) + '...',
});
} else {
output.newLine();
output.keyValue('Email', response.data.email);
if (response.data.username) {
output.keyValue('Username', response.data.username);
}
if (response.data.name) {
output.keyValue('Name', response.data.name);
}
output.keyValue('API URL', apiUrl);
output.keyValue('Auth Source', authSource === 'env' ? 'Environment variable' : 'Config file');
output.keyValue('Key Prefix', apiKey.substring(0, 12) + '...');
}
} catch (error) {
spinner.fail('Failed to verify authentication');
if (flags.json) {
output.json({
authenticated: false,
authSource,
apiUrl,
error: error instanceof Error ? error.message : 'Unknown error',
});
} else {
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}
}

View file

@ -0,0 +1,64 @@
import { Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class Whoami extends Command {
static description = 'Show current user information';
static examples = ['<%= config.bin %> <%= command.id %>'];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Whoami);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
const spinner = output.spinner('Fetching user info...');
try {
const response = await client.whoami();
if (!response.success || !response.data) {
spinner.fail('Failed to fetch user info');
return;
}
spinner.stop();
if (flags.json) {
output.json(response.data);
} else {
output.keyValue('Email', response.data.email);
if (response.data.username) {
output.keyValue('Username', response.data.username);
}
if (response.data.name) {
output.keyValue('Name', response.data.name);
}
output.keyValue('User ID', response.data.id);
}
} catch (error) {
spinner.fail('Failed to fetch user info');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,77 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class CollectionAdd extends Command {
static description = 'Add tools to a collection';
static examples = [
'<%= config.bin %> <%= command.id %> my-collection tool-id-1',
'<%= config.bin %> <%= command.id %> my-collection tool-id-1 tool-id-2 tool-id-3',
];
static strict = false; // Allow variable number of arguments
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
collection: Args.string({
description: 'Collection ID or slug',
required: true,
}),
};
async run(): Promise<void> {
const { args, argv, flags } = await this.parse(CollectionAdd);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
// Get tool IDs from remaining arguments
const toolIds = argv.slice(1) as string[];
if (toolIds.length === 0) {
output.error('Please specify at least one tool ID to add');
output.text('Example: tpm collection add my-collection tool-id-1 tool-id-2');
return;
}
const spinner = output.spinner(`Adding ${toolIds.length} tool(s)...`);
try {
await client.addToolsToCollection(args.collection, toolIds);
spinner.stop();
if (flags.json) {
output.json({ success: true, added: toolIds.length, toolIds });
return;
}
output.success(`Added ${toolIds.length} tool(s) to collection`);
for (const toolId of toolIds) {
output.listItem(toolId);
}
} catch (error) {
spinner.fail('Failed to add tools');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,86 @@
import { Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class CollectionCreate extends Command {
static description = 'Create a new collection';
static examples = [
'<%= config.bin %> <%= command.id %> --name "My Tools"',
'<%= config.bin %> <%= command.id %> --name "Web Scrapers" --description "Tools for web scraping" --public',
];
static flags = {
name: Flags.string({
char: 'n',
description: 'Collection name',
required: true,
}),
description: Flags.string({
char: 'd',
description: 'Collection description',
}),
public: Flags.boolean({
description: 'Make collection public',
default: false,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(CollectionCreate);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
const spinner = output.spinner('Creating collection...');
try {
const response = await client.createCollection({
name: flags.name,
description: flags.description,
isPublic: flags.public,
});
spinner.stop();
if (!response.success || !response.data) {
output.error(response.message || 'Failed to create collection');
return;
}
if (flags.json) {
output.json(response.data);
return;
}
output.success(`Collection "${response.data.name}" created successfully`);
output.newLine();
output.keyValue('ID', response.data.id);
if (response.data.slug) {
output.keyValue('Slug', response.data.slug);
}
output.keyValue('Public', response.data.isPublic ? 'Yes' : 'No');
output.newLine();
output.text(`Add tools: tpm collection add ${response.data.id} <tool-id>`);
} catch (error) {
spinner.fail('Failed to create collection');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,103 @@
import { Args, Command, Flags } from '@oclif/core';
import * as readline from 'node:readline';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class CollectionDelete extends Command {
static description = 'Delete a collection';
static examples = [
'<%= config.bin %> <%= command.id %> my-collection',
'<%= config.bin %> <%= command.id %> my-collection --force',
];
static flags = {
force: Flags.boolean({
char: 'f',
description: 'Skip confirmation prompt',
default: false,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
id: Args.string({
description: 'Collection ID or slug',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(CollectionDelete);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
// Get collection info first
const collectionResponse = await client.getCollection(args.id);
if (!collectionResponse.success || !collectionResponse.data) {
output.error('Collection not found');
return;
}
const collection = collectionResponse.data;
// Confirm deletion
if (!flags.force) {
const confirmed = await this.confirm(
`Are you sure you want to delete collection "${collection.name}"? This cannot be undone.`
);
if (!confirmed) {
output.info('Deletion cancelled');
return;
}
}
const spinner = output.spinner('Deleting collection...');
try {
await client.deleteCollection(args.id);
spinner.stop();
if (flags.json) {
output.json({ success: true, deleted: args.id });
return;
}
output.success(`Collection "${collection.name}" deleted successfully`);
} catch (error) {
spinner.fail('Failed to delete collection');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
private async confirm(message: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${message} [y/N] `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}
}

View file

@ -0,0 +1,124 @@
import { Args, Command, Flags } from '@oclif/core';
import * as fs from 'node:fs';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class CollectionImport extends Command {
static description = 'Import tools to a collection from a file';
static examples = [
'<%= config.bin %> <%= command.id %> my-collection --file tools.txt',
'<%= config.bin %> <%= command.id %> my-collection --file tools.json',
];
static flags = {
file: Flags.string({
char: 'f',
description: 'File containing tool IDs (one per line or JSON array)',
required: true,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
collection: Args.string({
description: 'Collection ID or slug',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(CollectionImport);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
// Read tool IDs from file
if (!fs.existsSync(flags.file)) {
output.error(`File not found: ${flags.file}`);
return;
}
const content = fs.readFileSync(flags.file, 'utf-8').trim();
let toolIds: string[];
// Try to parse as JSON first
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
toolIds = parsed.map(String);
} else {
output.error('JSON file must contain an array of tool IDs');
return;
}
} catch {
// Parse as line-separated text
toolIds = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
}
if (toolIds.length === 0) {
output.error('No tool IDs found in file');
return;
}
output.info(`Found ${toolIds.length} tool(s) in file`);
const spinner = output.spinner(`Adding ${toolIds.length} tool(s)...`);
try {
let added = 0;
let failed = 0;
const errors: string[] = [];
for (const toolId of toolIds) {
try {
await client.addToolsToCollection(args.collection, [toolId]);
added++;
} catch (error) {
failed++;
errors.push(`${toolId}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
spinner.stop();
if (flags.json) {
output.json({ success: true, added, failed, errors });
return;
}
if (added > 0) {
output.success(`Added ${added} tool(s) to collection`);
}
if (failed > 0) {
output.warning(`Failed to add ${failed} tool(s)`);
if (flags.verbose) {
for (const err of errors) {
output.listItem(err);
}
}
}
} catch (error) {
spinner.fail('Import failed');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,98 @@
import { Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class CollectionList extends Command {
static description = 'List your collections';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --limit 10',
];
static flags = {
limit: Flags.integer({
char: 'l',
description: 'Maximum number of results',
default: 20,
}),
offset: Flags.integer({
char: 'o',
description: 'Offset for pagination',
default: 0,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(CollectionList);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
const spinner = output.spinner('Fetching collections...');
try {
const response = await client.listCollections({
limit: flags.limit,
offset: flags.offset,
});
spinner.stop();
if (flags.json) {
output.json(response);
return;
}
if (response.data.length === 0) {
output.info('No collections found');
output.text('Create one with: tpm collection create');
return;
}
output.table(
response.data.map((collection) => ({
name: collection.name,
slug: collection.slug || '-',
public: collection.isPublic ? 'Yes' : 'No',
tools: collection._count?.tools ?? 0,
likes: collection.likeCount,
})),
[
{ key: 'name', header: 'Name', width: 30 },
{ key: 'slug', header: 'Slug', width: 25 },
{ key: 'public', header: 'Public', width: 8 },
{ key: 'tools', header: 'Tools', width: 8 },
{ key: 'likes', header: 'Likes', width: 8 },
]
);
output.newLine();
output.text(
output.dim(
`Showing ${response.data.length} collection(s)` +
(response.pagination.hasMore ? ` (more available)` : '')
)
);
} catch (error) {
spinner.fail('Failed to fetch collections');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,66 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class CollectionRemove extends Command {
static description = 'Remove a tool from a collection';
static examples = [
'<%= config.bin %> <%= command.id %> my-collection tool-id-1',
];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
collection: Args.string({
description: 'Collection ID or slug',
required: true,
}),
tool: Args.string({
description: 'Tool ID to remove',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(CollectionRemove);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
const spinner = output.spinner('Removing tool...');
try {
await client.removeToolFromCollection(args.collection, args.tool);
spinner.stop();
if (flags.json) {
output.json({ success: true, removed: args.tool });
return;
}
output.success(`Removed tool from collection`);
} catch (error) {
spinner.fail('Failed to remove tool');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,91 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class CollectionUpdate extends Command {
static description = 'Update a collection';
static examples = [
'<%= config.bin %> <%= command.id %> my-collection --name "New Name"',
'<%= config.bin %> <%= command.id %> my-collection --public',
];
static flags = {
name: Flags.string({
char: 'n',
description: 'Collection name',
}),
description: Flags.string({
char: 'd',
description: 'Collection description',
}),
public: Flags.boolean({
description: 'Make collection public',
allowNo: true,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
id: Args.string({
description: 'Collection ID or slug',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(CollectionUpdate);
const output = createOutput(flags);
const client = getClient();
if (!client.isAuthenticated()) {
output.error('Not authenticated. Run `tpm auth login` first.');
return;
}
// Build update payload
const updates: Record<string, unknown> = {};
if (flags.name) updates.name = flags.name;
if (flags.description !== undefined) updates.description = flags.description;
if (flags.public !== undefined) updates.isPublic = flags.public;
if (Object.keys(updates).length === 0) {
output.error('No updates specified. Use --help to see available options.');
return;
}
const spinner = output.spinner('Updating collection...');
try {
const response = await client.updateCollection(args.id, updates);
spinner.stop();
if (!response.success || !response.data) {
output.error(response.message || 'Failed to update collection');
return;
}
if (flags.json) {
output.json(response.data);
return;
}
output.success(`Collection "${response.data.name}" updated successfully`);
} catch (error) {
spinner.fail('Failed to update collection');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,180 @@
import { Command, Flags } from '@oclif/core';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { getApiKey, getApiUrl, getConfigDir, hasCredentials, getConfig } from '../lib/config.js';
import { TpmClient } from '../lib/api-client.js';
import { createOutput } from '../lib/output.js';
interface DiagnosticCheck {
name: string;
status: 'ok' | 'warning' | 'error';
message: string;
details?: string;
}
export default class Doctor extends Command {
static description = 'Run diagnostic checks for TPMJS CLI';
static examples = ['<%= config.bin %> <%= command.id %>'];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Doctor);
const output = createOutput(flags);
const checks: DiagnosticCheck[] = [];
output.heading('TPMJS CLI Diagnostics');
// 1. Check Node.js version
const nodeVersion = process.version;
const nodeVersionNum = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
checks.push({
name: 'Node.js Version',
status: nodeVersionNum >= 18 ? 'ok' : 'error',
message: nodeVersion,
details: nodeVersionNum < 18 ? 'Node.js 18+ is required' : undefined,
});
// 2. Check config directory
const configDir = getConfigDir();
const configExists = fs.existsSync(configDir);
checks.push({
name: 'Config Directory',
status: configExists ? 'ok' : 'warning',
message: configDir,
details: configExists ? undefined : 'Config directory will be created on first use',
});
// 3. Check authentication
const hasAuth = hasCredentials() || !!process.env.TPMJS_API_KEY;
const authSource = process.env.TPMJS_API_KEY ? 'environment' : hasCredentials() ? 'config file' : 'none';
checks.push({
name: 'Authentication',
status: hasAuth ? 'ok' : 'warning',
message: hasAuth ? `Configured via ${authSource}` : 'Not configured',
details: hasAuth ? undefined : 'Run `tpm auth login` to authenticate',
});
// 4. Check API connectivity
const apiUrl = getApiUrl();
try {
const client = new TpmClient();
const healthResponse = await client.health();
checks.push({
name: 'API Connectivity',
status: 'ok',
message: `Connected to ${apiUrl}`,
details: `Server status: ${healthResponse.status}`,
});
} catch (error) {
checks.push({
name: 'API Connectivity',
status: 'error',
message: `Cannot connect to ${apiUrl}`,
details: error instanceof Error ? error.message : 'Unknown error',
});
}
// 5. Check API authentication (if credentials exist)
if (hasAuth) {
const apiKey = getApiKey();
if (apiKey) {
try {
const client = new TpmClient({ apiKey });
const whoamiResponse = await client.whoami();
if (whoamiResponse.success && whoamiResponse.data) {
checks.push({
name: 'API Authentication',
status: 'ok',
message: `Authenticated as ${whoamiResponse.data.email}`,
});
} else {
checks.push({
name: 'API Authentication',
status: 'error',
message: 'API key is invalid',
details: 'Run `tpm auth login` to re-authenticate',
});
}
} catch (error) {
checks.push({
name: 'API Authentication',
status: 'error',
message: 'Failed to verify API key',
details: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}
// 6. Check config file
const config = getConfig();
checks.push({
name: 'Configuration',
status: 'ok',
message: 'Loaded',
details: flags.verbose ? JSON.stringify(config, null, 2) : undefined,
});
// 7. Check disk space (warning if less than 100MB)
try {
const homeDir = os.homedir();
const stats = fs.statfsSync(homeDir);
const freeSpaceBytes = stats.bavail * stats.bsize;
const freeSpaceMB = Math.floor(freeSpaceBytes / (1024 * 1024));
checks.push({
name: 'Disk Space',
status: freeSpaceMB > 100 ? 'ok' : 'warning',
message: `${freeSpaceMB} MB available`,
details: freeSpaceMB <= 100 ? 'Low disk space may cause issues' : undefined,
});
} catch {
// Ignore disk space check errors on unsupported platforms
}
// Output results
if (flags.json) {
const summary = {
ok: checks.filter((c) => c.status === 'ok').length,
warnings: checks.filter((c) => c.status === 'warning').length,
errors: checks.filter((c) => c.status === 'error').length,
};
output.json({ checks, summary });
return;
}
for (const check of checks) {
const icon = check.status === 'ok' ? '✓' : check.status === 'warning' ? '⚠' : '✗';
output.text(`${icon} ${output.bold(check.name)}: ${check.message}`);
if (check.details && (flags.verbose || check.status !== 'ok')) {
output.text(` ${output.dim(check.details)}`);
}
}
output.newLine();
const errorCount = checks.filter((c) => c.status === 'error').length;
const warningCount = checks.filter((c) => c.status === 'warning').length;
if (errorCount > 0) {
output.error(`${errorCount} error(s) found`);
} else if (warningCount > 0) {
output.warning(`${warningCount} warning(s) found`);
} else {
output.success('All checks passed');
}
}
}

View file

@ -0,0 +1,144 @@
import { Args, Command, Flags } from '@oclif/core';
import * as fs from 'node:fs';
import { createOutput } from '../../lib/output.js';
type ClientType = 'claude' | 'cursor' | 'windsurf' | 'generic';
export default class McpConfig extends Command {
static description = 'Generate MCP configuration for AI clients';
static examples = [
'<%= config.bin %> <%= command.id %> ajax/ajax-collection',
'<%= config.bin %> <%= command.id %> ajax/ajax-collection --client cursor',
'<%= config.bin %> <%= command.id %> ajax/ajax-collection --output ~/Library/Application\\ Support/Claude/claude_desktop_config.json',
];
static flags = {
client: Flags.string({
char: 'c',
description: 'Target client (claude, cursor, windsurf, generic)',
default: 'claude',
options: ['claude', 'cursor', 'windsurf', 'generic'],
}),
output: Flags.string({
char: 'o',
description: 'Output file path (will merge with existing config)',
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
'api-key': Flags.string({
char: 'k',
description: 'API key to include in config (optional)',
}),
};
static args = {
collection: Args.string({
description: 'Collection path (username/slug)',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(McpConfig);
const output = createOutput(flags);
const [username, slug] = args.collection.split('/');
if (!username || !slug) {
output.error('Invalid collection path. Use format: username/collection-slug');
return;
}
const mcpUrl = `https://tpmjs.com/api/mcp/${username}/${slug}/sse`;
const clientType = flags.client as ClientType;
// Generate config based on client type
const config = generateConfig(clientType, mcpUrl, slug, flags['api-key']);
if (flags.json) {
output.json(config);
return;
}
if (flags.output) {
// Merge with existing config if file exists
let existingConfig: Record<string, unknown> = {};
if (fs.existsSync(flags.output)) {
try {
const content = fs.readFileSync(flags.output, 'utf-8');
existingConfig = JSON.parse(content);
} catch {
output.warning(`Could not parse existing config at ${flags.output}, creating new file`);
}
}
// Merge mcpServers
const existingServers = typeof existingConfig.mcpServers === 'object' && existingConfig.mcpServers !== null
? existingConfig.mcpServers as Record<string, unknown>
: {};
const mergedConfig = {
...existingConfig,
mcpServers: {
...existingServers,
...(config.mcpServers as Record<string, unknown>),
},
};
fs.writeFileSync(flags.output, JSON.stringify(mergedConfig, null, 2));
output.success(`Config written to ${flags.output}`);
return;
}
// Output config to console
output.heading(`MCP Config for ${clientType}`);
output.text(output.dim(`Collection: ${args.collection}`));
output.text(output.dim(`URL: ${mcpUrl}`));
output.newLine();
output.subheading('Add to your config file:');
output.code(JSON.stringify(config, null, 2), 'json');
output.newLine();
output.text(output.dim(getConfigPath(clientType)));
}
}
function generateConfig(
_client: ClientType,
mcpUrl: string,
name: string,
apiKey?: string
): Record<string, unknown> {
const serverName = `tpmjs-${name}`;
const baseConfig = {
mcpServers: {
[serverName]: {
command: 'npx',
args: ['-y', '@anthropic/mcp-remote', mcpUrl],
...(apiKey && {
env: {
TPMJS_API_KEY: apiKey,
},
}),
},
},
};
return baseConfig;
}
function getConfigPath(client: ClientType): string {
switch (client) {
case 'claude':
return 'Config location: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)';
case 'cursor':
return 'Config location: ~/.cursor/mcp.json';
case 'windsurf':
return 'Config location: ~/.windsurf/mcp.json';
default:
return 'Consult your MCP client documentation for config location';
}
}

View file

@ -0,0 +1,301 @@
import { Command, Flags } from '@oclif/core';
import * as http from 'node:http';
import * as readline from 'node:readline';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
interface MCPRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: Record<string, unknown>;
}
interface MCPResponse {
jsonrpc: '2.0';
id: string | number;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
export default class MCPServe extends Command {
static description = 'Run as a local MCP server';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --port 8080',
'<%= config.bin %> <%= command.id %> --stdio',
'<%= config.bin %> <%= command.id %> --collection my-collection',
];
static flags = {
port: Flags.integer({
char: 'p',
description: 'Port to run the server on (HTTP mode)',
default: 3333,
}),
stdio: Flags.boolean({
description: 'Use stdio transport instead of HTTP',
default: false,
}),
collection: Flags.string({
char: 'c',
description: 'Serve tools from a specific collection',
}),
tool: Flags.string({
char: 't',
description: 'Serve specific tools (comma-separated)',
multiple: true,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
private client = getClient();
private tools: Map<string, unknown> = new Map();
async run(): Promise<void> {
const { flags } = await this.parse(MCPServe);
const output = createOutput(flags);
// Load tools
await this.loadTools(flags, output);
if (flags.stdio) {
await this.runStdioServer(output, flags.verbose);
} else {
await this.runHttpServer(flags.port, output, flags.verbose);
}
}
private async loadTools(
flags: { collection?: string; tool?: string[] },
output: ReturnType<typeof createOutput>
): Promise<void> {
const spinner = output.spinner('Loading tools...');
try {
if (flags.collection) {
// Load tools from collection - search for tools with collection filter
// For now, just load trending tools if collection specified
const response = await this.client.getTrendingTools({ limit: 20 });
if (response.data && response.data.length > 0) {
for (const tool of response.data) {
this.tools.set(tool.slug, tool);
}
}
} else if (flags.tool && flags.tool.length > 0) {
// Load specific tools by slug
for (const toolId of flags.tool) {
const response = await this.client.getToolBySlug(toolId);
if (response.success && response.data) {
this.tools.set(response.data.slug, response.data);
}
}
} else {
// Load trending tools as default
const response = await this.client.getTrendingTools({ limit: 10 });
if (response.data && response.data.length > 0) {
for (const tool of response.data) {
this.tools.set(tool.slug, tool);
}
}
}
spinner.stop();
output.info(`Loaded ${this.tools.size} tool(s)`);
} catch (error) {
spinner.fail('Failed to load tools');
throw error;
}
}
private async runStdioServer(
output: ReturnType<typeof createOutput>,
verbose: boolean
): Promise<void> {
output.info('Starting MCP server in stdio mode...');
output.info('Listening for JSON-RPC messages on stdin');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
});
rl.on('line', async (line) => {
try {
const request = JSON.parse(line) as MCPRequest;
if (verbose) {
output.info(`Received: ${request.method}`);
}
const response = await this.handleRequest(request);
console.log(JSON.stringify(response));
} catch (error) {
const errorResponse: MCPResponse = {
jsonrpc: '2.0',
id: 0,
error: {
code: -32700,
message: 'Parse error',
data: error instanceof Error ? error.message : 'Unknown error',
},
};
console.log(JSON.stringify(errorResponse));
}
});
// Keep process running
await new Promise(() => {});
}
private async runHttpServer(
port: number,
output: ReturnType<typeof createOutput>,
verbose: boolean
): Promise<void> {
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/mcp') {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const request = JSON.parse(body) as MCPRequest;
if (verbose) {
output.info(`Received: ${request.method}`);
}
const response = await this.handleRequest(request);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
} catch (error) {
const errorResponse: MCPResponse = {
jsonrpc: '2.0',
id: 0,
error: {
code: -32700,
message: 'Parse error',
data: error instanceof Error ? error.message : 'Unknown error',
},
};
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(errorResponse));
}
});
} else if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', tools: this.tools.size }));
} else {
res.writeHead(404);
res.end('Not found');
}
});
server.listen(port, () => {
output.success(`MCP server running at http://localhost:${port}/mcp`);
output.info('Health check: GET /health');
output.info('Press Ctrl+C to stop');
});
// Keep process running
await new Promise(() => {});
}
private async handleRequest(request: MCPRequest): Promise<MCPResponse> {
const { id, method, params } = request;
switch (method) {
case 'initialize':
return {
jsonrpc: '2.0',
id,
result: {
protocolVersion: '2024-11-05',
capabilities: {
tools: {},
},
serverInfo: {
name: 'tpmjs-mcp-server',
version: '0.1.0',
},
},
};
case 'tools/list':
return {
jsonrpc: '2.0',
id,
result: {
tools: Array.from(this.tools.entries()).map(([slug, tool]) => ({
name: slug,
description: (tool as Record<string, unknown>).description || '',
inputSchema: (tool as Record<string, unknown>).inputSchema || {
type: 'object',
properties: {},
},
})),
},
};
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string;
const toolArgs = (params as Record<string, unknown>)?.arguments as Record<string, unknown>;
if (!toolName) {
return {
jsonrpc: '2.0',
id,
error: {
code: -32602,
message: 'Invalid params: tool name required',
},
};
}
try {
const response = await this.client.executeTool(toolName, toolArgs || {});
return {
jsonrpc: '2.0',
id,
result: {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2),
},
],
},
};
} catch (error) {
return {
jsonrpc: '2.0',
id,
error: {
code: -32000,
message: error instanceof Error ? error.message : 'Tool execution failed',
},
};
}
}
default:
return {
jsonrpc: '2.0',
id,
error: {
code: -32601,
message: `Method not found: ${method}`,
},
};
}
}
}

View file

@ -0,0 +1,283 @@
import { Command, Flags } from '@oclif/core';
import * as readline from 'node:readline';
import { getClient } from '../lib/api-client.js';
import { createOutput } from '../lib/output.js';
export default class Playground extends Command {
static description = 'Interactive playground for testing tools';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --tool firecrawl-scrape',
'<%= config.bin %> <%= command.id %> --web',
];
static flags = {
tool: Flags.string({
char: 't',
description: 'Start with a specific tool selected',
}),
web: Flags.boolean({
char: 'w',
description: 'Open the web playground instead',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
private rl?: readline.Interface;
private client = getClient();
private selectedTool?: string;
async run(): Promise<void> {
const { flags } = await this.parse(Playground);
const output = createOutput(flags);
if (flags.web) {
output.info('Opening web playground...');
const open = (await import('open')).default;
await open('https://tpmjs.com/playground');
return;
}
this.selectedTool = flags.tool;
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
output.heading('TPMJS Playground');
output.text('');
output.text('Commands:');
output.listItem('.help - Show this help');
output.listItem('.tools - List available tools');
output.listItem('.select - Select a tool to use');
output.listItem('.info - Show info about selected tool');
output.listItem('.clear - Clear the screen');
output.listItem('.exit - Exit the playground');
output.text('');
if (this.selectedTool) {
output.info(`Selected tool: ${this.selectedTool}`);
} else {
output.text('No tool selected. Use .select to choose a tool.');
}
output.text('');
output.text('Enter JSON input to execute the selected tool.');
output.divider();
await this.repl(output, flags.verbose);
}
private async repl(output: ReturnType<typeof createOutput>, verbose: boolean): Promise<void> {
const prompt = () => {
const prefix = this.selectedTool ? `[${this.selectedTool}]` : '[no tool]';
this.rl?.question(`${prefix} > `, async (input) => {
const trimmed = input.trim();
if (!trimmed) {
prompt();
return;
}
if (trimmed.startsWith('.')) {
await this.handleCommand(trimmed, output, verbose);
if (trimmed !== '.exit') {
prompt();
}
return;
}
// Try to execute as JSON input
if (!this.selectedTool) {
output.warning('No tool selected. Use .select to choose a tool first.');
prompt();
return;
}
try {
const params = JSON.parse(trimmed);
await this.executeTool(params, output, verbose);
} catch {
output.error('Invalid JSON input. Enter valid JSON or use a command (.help)');
}
prompt();
});
};
prompt();
// Keep running until exit
await new Promise<void>((resolve) => {
this.rl?.on('close', resolve);
});
}
private async handleCommand(
command: string,
output: ReturnType<typeof createOutput>,
verbose: boolean
): Promise<void> {
const [cmd, ...args] = command.split(/\s+/);
switch (cmd) {
case '.help':
output.text('');
output.text('Commands:');
output.listItem('.help - Show this help');
output.listItem('.tools - List available tools');
output.listItem('.select <tool> - Select a tool to use');
output.listItem('.info - Show info about selected tool');
output.listItem('.clear - Clear the screen');
output.listItem('.exit - Exit the playground');
output.text('');
output.text('To execute a tool, enter JSON input:');
output.text(' {"url": "https://example.com"}');
output.text('');
break;
case '.tools':
await this.listTools(output);
break;
case '.select':
if (args.length === 0) {
output.warning('Usage: .select <tool-slug>');
} else {
this.selectedTool = args[0];
output.success(`Selected: ${this.selectedTool}`);
}
break;
case '.info':
await this.showToolInfo(output, verbose);
break;
case '.clear':
console.clear();
break;
case '.exit':
output.info('Goodbye!');
this.rl?.close();
break;
default:
output.warning(`Unknown command: ${cmd}. Type .help for available commands.`);
}
}
private async listTools(output: ReturnType<typeof createOutput>): Promise<void> {
const spinner = output.spinner('Loading tools...');
try {
const response = await this.client.getTrendingTools({ limit: 20 });
spinner.stop();
if (!response.data || response.data.length === 0) {
output.error('No tools found');
return;
}
output.text('');
output.text('Available tools:');
for (const tool of response.data) {
output.listItem(`${tool.slug} - ${tool.name}`);
}
output.text('');
output.text('Use .select <slug> to select a tool');
} catch {
spinner.fail('Failed to load tools');
}
}
private async showToolInfo(
output: ReturnType<typeof createOutput>,
verbose: boolean
): Promise<void> {
if (!this.selectedTool) {
output.warning('No tool selected');
return;
}
const spinner = output.spinner('Loading tool info...');
try {
const response = await this.client.getToolBySlug(this.selectedTool);
spinner.stop();
if (!response.success || !response.data) {
output.error('Tool not found');
return;
}
const tool = response.data;
output.text('');
output.heading(tool.name);
output.text(`Slug: ${tool.slug}`);
output.text(`Category: ${tool.category}`);
output.text(`Version: ${tool.npmVersion}`);
output.text('');
output.text('Description:');
output.text(` ${tool.description || '(none)'}`);
if (tool.inputSchema) {
output.text('');
output.text('Input Schema:');
output.text(JSON.stringify(tool.inputSchema, null, 2));
}
if (verbose && tool.tools && tool.tools.length > 0) {
output.text('');
output.text('Available operations:');
for (const t of tool.tools) {
output.listItem(`${t.name}: ${t.description || ''}`);
}
}
output.text('');
} catch {
spinner.fail('Failed to load tool info');
}
}
private async executeTool(
params: Record<string, unknown>,
output: ReturnType<typeof createOutput>,
verbose: boolean
): Promise<void> {
if (!this.selectedTool) {
output.warning('No tool selected');
return;
}
const spinner = output.spinner('Executing...');
try {
const result = await this.client.executeTool(this.selectedTool, params);
spinner.stop();
output.success('Result:');
output.text(JSON.stringify(result, null, 2));
} catch (error) {
spinner.fail('Execution failed');
output.error(
error instanceof Error ? error.message : 'Unknown error',
verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,120 @@
import { Args, Command, Flags } from '@oclif/core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class PublishCheck extends Command {
static description = 'Check if your package has been discovered by tpmjs.com';
static examples = [
'<%= config.bin %> <%= command.id %> @myorg/my-tool',
'<%= config.bin %> <%= command.id %>',
];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
package: Args.string({
description: 'npm package name (defaults to current directory)',
required: false,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(PublishCheck);
const output = createOutput(flags);
const client = getClient();
// Determine package name
let packageName = args.package as string | undefined;
if (!packageName) {
const packagePath = path.resolve('.', 'package.json');
if (fs.existsSync(packagePath)) {
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
packageName = packageJson.name;
}
}
if (!packageName) {
output.error('No package name provided and no package.json found');
return;
}
output.info(`Checking discovery status for: ${packageName}`);
const spinner = output.spinner('Checking tpmjs.com...');
try {
// Search for the package by npm name
const searchResponse = await client.searchTools({ query: packageName, limit: 10 });
spinner.stop();
if (!searchResponse.data || searchResponse.data.length === 0) {
// No results found
}
// Find exact match
const exactMatch = searchResponse.data?.find(
(tool) => tool.npmPackageName === packageName
);
if (flags.json) {
output.json({
packageName,
discovered: Boolean(exactMatch),
tool: exactMatch || null,
});
return;
}
if (exactMatch) {
output.success('Package has been discovered!');
output.divider();
output.text(`Name: ${exactMatch.name}`);
output.text(`Slug: ${exactMatch.slug}`);
output.text(`Category: ${exactMatch.category}`);
output.text(`Tier: ${exactMatch.tier}`);
output.text(`Version: ${exactMatch.npmVersion}`);
output.text(`Downloads (last month): ${exactMatch.npmDownloadsLastMonth?.toLocaleString() || 'N/A'}`);
output.text(`Quality Score: ${exactMatch.qualityScore?.toFixed(2) || 'N/A'}`);
output.text('');
output.text(`View on tpmjs.com:`);
output.text(` https://tpmjs.com/tools/${exactMatch.slug}`);
} else {
output.warning('Package not yet discovered');
output.text('');
output.text('Possible reasons:');
output.listItem('Package was recently published (sync runs every 2-15 minutes)');
output.listItem('Missing "tpmjs" keyword in package.json keywords array');
output.listItem('Missing or invalid "tpmjs" field in package.json');
output.text('');
output.text('To publish a TPMJS tool:');
output.listItem('Add "tpmjs" to your package.json keywords');
output.listItem('Add a valid "tpmjs" field with category and tools');
output.listItem('Run `tpm tool validate` to check your configuration');
output.listItem('Publish to npm with `npm publish`');
output.text('');
output.text('After publishing, your tool should appear within 15 minutes.');
}
} catch (error) {
spinner.fail('Failed to check status');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,171 @@
import { Command, Flags } from '@oclif/core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createOutput } from '../../lib/output.js';
export default class PublishPreview extends Command {
static description = 'Preview how your tool will appear on tpmjs.com';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --path ./my-tool',
];
static flags = {
path: Flags.string({
char: 'p',
description: 'Path to package directory',
default: '.',
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(PublishPreview);
const output = createOutput(flags);
const packagePath = path.resolve(flags.path, 'package.json');
if (!fs.existsSync(packagePath)) {
output.error(`No package.json found at ${packagePath}`);
return;
}
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
const tpmjs = packageJson.tpmjs;
if (!tpmjs) {
output.error('No tpmjs field found in package.json');
output.text('Run `tpm tool validate` for detailed validation');
return;
}
// Build preview data
const preview = {
name: tpmjs.name || packageJson.name,
description: tpmjs.description || packageJson.description,
category: tpmjs.category,
tier: this.determineTier(tpmjs),
slug: this.generateSlug(tpmjs.name || packageJson.name),
version: packageJson.version,
author: this.extractAuthor(packageJson),
repository: this.extractRepo(packageJson),
tools: tpmjs.tools || [],
tags: tpmjs.tags || [],
documentation: tpmjs.documentation,
examples: tpmjs.examples,
};
if (flags.json) {
output.json(preview);
return;
}
// Display preview
output.heading('Tool Preview');
output.divider();
output.text(`Name: ${preview.name}`);
output.text(`Slug: ${preview.slug}`);
output.text(`Version: ${preview.version}`);
output.text(`Category: ${preview.category}`);
output.text(`Tier: ${preview.tier}`);
output.text('');
output.text(`Description:`);
output.text(` ${preview.description || '(none)'}`);
if (preview.author) {
output.text('');
output.text(`Author: ${preview.author}`);
}
if (preview.repository) {
output.text(`Repository: ${preview.repository}`);
}
if (preview.tools.length > 0) {
output.text('');
output.text(`Tools (${preview.tools.length}):`);
for (const tool of preview.tools) {
if (typeof tool === 'string') {
output.listItem(tool);
} else {
output.listItem(`${tool.name}: ${tool.description || ''}`);
}
}
}
if (preview.tags.length > 0) {
output.text('');
output.text(`Tags: ${preview.tags.join(', ')}`);
}
if (preview.documentation) {
output.text('');
output.text(`Documentation: ${preview.documentation}`);
}
if (preview.examples && preview.examples.length > 0) {
output.text('');
output.text(`Examples: ${preview.examples.length} example(s) provided`);
}
output.divider();
output.text('');
output.info('This is how your tool will appear on tpmjs.com');
output.text('Run `tpm tool validate` to check for issues before publishing');
}
private determineTier(tpmjs: Record<string, unknown>): string {
// Rich tier requires: tools array with schemas, examples, documentation
const hasTools = Array.isArray(tpmjs.tools) && tpmjs.tools.length > 0;
const hasExamples = Array.isArray(tpmjs.examples) && tpmjs.examples.length > 0;
const hasDocumentation = Boolean(tpmjs.documentation);
if (hasTools && hasExamples && hasDocumentation) {
return 'rich';
}
return 'minimal';
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/@/g, '')
.replace(/\//g, '-')
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
private extractAuthor(packageJson: Record<string, unknown>): string | undefined {
const author = packageJson.author;
if (typeof author === 'string') {
return author;
}
if (author && typeof author === 'object') {
const authorObj = author as Record<string, string>;
return authorObj.name || authorObj.email;
}
return undefined;
}
private extractRepo(packageJson: Record<string, unknown>): string | undefined {
const repo = packageJson.repository;
if (typeof repo === 'string') {
return repo;
}
if (repo && typeof repo === 'object') {
return (repo as Record<string, string>).url;
}
return undefined;
}
}

View file

@ -0,0 +1,149 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class ToolExecute extends Command {
static description = 'Execute a TPMJS tool';
static examples = [
'<%= config.bin %> <%= command.id %> firecrawl-scrape --input \'{"url":"https://example.com"}\'',
'<%= config.bin %> <%= command.id %> my-tool --input-file params.json',
'<%= config.bin %> <%= command.id %> my-tool --stream',
];
static flags = {
input: Flags.string({
char: 'i',
description: 'Input parameters as JSON string',
}),
'input-file': Flags.string({
char: 'f',
description: 'Path to JSON file containing input parameters',
}),
stream: Flags.boolean({
char: 's',
description: 'Stream output (for tools that support it)',
default: false,
}),
timeout: Flags.integer({
char: 't',
description: 'Timeout in seconds',
default: 300,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
tool: Args.string({
description: 'Tool slug or ID',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(ToolExecute);
const output = createOutput(flags);
const client = getClient();
// Parse input parameters
let params: Record<string, unknown> = {};
if (flags.input) {
try {
params = JSON.parse(flags.input);
} catch {
output.error('Invalid JSON in --input flag');
return;
}
} else if (flags['input-file']) {
try {
const fs = await import('node:fs');
const content = fs.readFileSync(flags['input-file'], 'utf-8');
params = JSON.parse(content);
} catch (error) {
output.error(`Failed to read input file: ${error instanceof Error ? error.message : 'Unknown error'}`);
return;
}
}
// Check stdin for piped input
if (!flags.input && !flags['input-file'] && !process.stdin.isTTY) {
try {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const stdinContent = Buffer.concat(chunks).toString('utf-8').trim();
if (stdinContent) {
params = JSON.parse(stdinContent);
}
} catch {
output.error('Failed to parse JSON from stdin');
return;
}
}
const spinner = flags.stream ? null : output.spinner(`Executing ${args.tool}...`);
try {
if (flags.stream) {
// Streaming execution
output.info(`Executing ${args.tool} with streaming...`);
output.divider();
const stream = client.executeToolStream(args.tool, params);
for await (const event of stream) {
if (event.type === 'text') {
process.stdout.write(event.data);
} else if (event.type === 'error') {
output.error(event.data);
} else if (event.type === 'done') {
output.text('');
output.divider();
output.success('Execution complete');
} else if (flags.verbose) {
output.info(`Event: ${event.type}`);
}
}
} else {
// Non-streaming execution
const result = await client.executeTool(args.tool, params);
spinner?.stop();
if (flags.json) {
output.json(result);
return;
}
output.success('Execution complete');
output.divider();
if (typeof result === 'string') {
output.text(result);
} else if (result && typeof result === 'object') {
// Pretty print the result
const formatted = JSON.stringify(result, null, 2);
output.text(formatted);
} else {
output.text(String(result));
}
}
} catch (error) {
spinner?.fail('Execution failed');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,110 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class ToolInfo extends Command {
static description = 'Get detailed information about a tool';
static examples = [
'<%= config.bin %> <%= command.id %> @tpmjs/official-firecrawl scrapeTool',
'<%= config.bin %> <%= command.id %> firecrawl-tool default',
];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
package: Args.string({
description: 'Package name (e.g., @tpmjs/official-firecrawl)',
required: true,
}),
tool: Args.string({
description: 'Tool name (e.g., scrapeTool)',
required: true,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(ToolInfo);
const output = createOutput(flags);
const client = getClient();
const spinner = output.spinner('Fetching tool info...');
try {
const response = await client.getTool(args.package, args.tool);
spinner.stop();
if (!response.success || !response.data) {
output.error('Tool not found');
return;
}
const tool = response.data;
if (flags.json) {
output.json(tool);
return;
}
output.heading(`${tool.name}`);
output.keyValue('Package', tool.package?.npmPackageName || tool.npmPackageName);
output.keyValue('Category', tool.package?.category || tool.category);
output.keyValue('Official', (tool.package?.isOfficial || tool.isOfficial) ? 'Yes' : 'No');
output.newLine();
output.subheading('Description');
output.text(tool.description || 'No description available');
output.newLine();
output.subheading('Health Status');
output.keyValue('Import', formatHealthBadge(tool.importHealth));
output.keyValue('Execution', formatHealthBadge(tool.executionHealth));
output.newLine();
output.subheading('Metrics');
output.keyValue('Quality Score', tool.qualityScore ? tool.qualityScore.toFixed(2) : 'N/A');
output.keyValue('Downloads/Month', formatDownloads(tool.package?.npmDownloadsLastMonth || tool.npmDownloadsLastMonth));
output.keyValue('Likes', tool.likeCount.toString());
output.newLine();
output.subheading('Links');
output.text(`Web: ${output.link('View on TPMJS', `https://tpmjs.com/tool/${args.package}/${args.tool}`)}`);
output.text(`npm: ${output.link('View on npm', `https://www.npmjs.com/package/${args.package}`)}`);
} catch (error) {
spinner.fail('Failed to fetch tool info');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}
function formatDownloads(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return count.toString();
}
function formatHealthBadge(health: string): string {
switch (health) {
case 'HEALTHY':
return '✓ Healthy';
case 'BROKEN':
return '✗ Broken';
default:
return '? Unknown';
}
}

View file

@ -0,0 +1,376 @@
import { Args, Command, Flags } from '@oclif/core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as readline from 'node:readline';
import { createOutput } from '../../lib/output.js';
const CATEGORIES = [
'research',
'web',
'data',
'documentation',
'engineering',
'security',
'statistics',
'ops',
'agent',
'sandbox',
'utilities',
'html',
'compliance',
];
export default class ToolInit extends Command {
static description = 'Initialize a new TPMJS tool package';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> my-tool',
'<%= config.bin %> <%= command.id %> --template minimal',
];
static flags = {
template: Flags.string({
char: 't',
description: 'Template to use',
options: ['minimal', 'rich'],
default: 'minimal',
}),
category: Flags.string({
char: 'c',
description: 'Tool category',
options: CATEGORIES,
}),
force: Flags.boolean({
char: 'f',
description: 'Overwrite existing files',
default: false,
}),
yes: Flags.boolean({
char: 'y',
description: 'Skip prompts and use defaults',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
name: Args.string({
description: 'Tool name (creates directory if not exists)',
required: false,
}),
};
private rl?: readline.Interface;
async run(): Promise<void> {
const { args, flags } = await this.parse(ToolInit);
const output = createOutput(flags);
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
// Gather configuration
const config = await this.gatherConfig(args.name as string | undefined, flags, output);
// Determine target directory
const targetDir = config.name ? path.resolve(config.name) : process.cwd();
// Check if files exist
const packageJsonPath = path.join(targetDir, 'package.json');
if (fs.existsSync(packageJsonPath) && !flags.force) {
output.error('package.json already exists. Use --force to overwrite.');
return;
}
// Create directory if needed
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Generate files
await this.generateFiles(targetDir, config, flags.template, output);
output.success('TPMJS tool initialized successfully!');
output.text('');
output.text('Next steps:');
output.listItem('Install dependencies: npm install');
output.listItem('Implement your tool in src/index.ts');
output.listItem('Validate: tpm tool validate');
output.listItem('Build: npm run build');
output.listItem('Publish: npm publish');
} finally {
this.rl?.close();
}
}
private async gatherConfig(
name: string | undefined,
flags: { category?: string; yes: boolean },
output: ReturnType<typeof createOutput>
): Promise<{
name: string;
description: string;
category: string;
author: string;
}> {
if (flags.yes) {
return {
name: name || 'my-tpmjs-tool',
description: 'A TPMJS tool',
category: flags.category || 'utilities',
author: process.env.USER || 'unknown',
};
}
output.heading('TPMJS Tool Initialization');
output.text('');
const toolName = name || (await this.prompt('Tool name: ')) || 'my-tpmjs-tool';
const description = (await this.prompt('Description: ')) || 'A TPMJS tool';
let category = flags.category;
if (!category) {
output.text('');
output.text('Available categories:');
CATEGORIES.forEach((cat, i) => {
output.text(` ${i + 1}. ${cat}`);
});
const catIndex = await this.prompt('Category (number or name): ');
const index = parseInt(catIndex, 10);
if (index > 0 && index <= CATEGORIES.length) {
category = CATEGORIES[index - 1];
} else if (CATEGORIES.includes(catIndex)) {
category = catIndex;
} else {
category = 'utilities';
}
}
const author = (await this.prompt(`Author (${process.env.USER}): `)) || process.env.USER || 'unknown';
return { name: toolName, description, category: category ?? 'utilities', author };
}
private prompt(question: string): Promise<string> {
return new Promise((resolve) => {
this.rl?.question(question, (answer) => {
resolve(answer.trim());
});
});
}
private async generateFiles(
targetDir: string,
config: { name: string; description: string; category: string; author: string },
template: string,
output: ReturnType<typeof createOutput>
): Promise<void> {
const spinner = output.spinner('Generating files...');
// package.json
const packageJson = {
name: config.name.startsWith('@') ? config.name : config.name,
version: '0.1.0',
description: config.description,
type: 'module',
main: './dist/index.js',
types: './dist/index.d.ts',
exports: {
'.': {
import: './dist/index.js',
types: './dist/index.d.ts',
},
},
files: ['dist'],
keywords: ['tpmjs', 'mcp', 'ai-tools', config.category],
author: config.author,
license: 'MIT',
scripts: {
build: 'tsup',
dev: 'tsup --watch',
'type-check': 'tsc --noEmit',
prepublishOnly: 'npm run build',
},
dependencies: {
ai: '^4.0.0',
},
devDependencies: {
'@types/node': '^20.0.0',
tsup: '^8.0.0',
typescript: '^5.0.0',
},
tpmjs: {
name: config.name.replace(/@[^/]+\//, '').replace(/-/g, ' '),
description: config.description,
category: config.category,
tools: [
{
name: 'myTool',
description: 'Description of what this tool does',
},
],
...(template === 'rich'
? {
documentation: 'https://github.com/yourname/' + config.name + '#readme',
examples: [
{
title: 'Basic usage',
code: 'const result = await myTool.execute({ input: "hello" });',
},
],
}
: {}),
},
};
fs.writeFileSync(
path.join(targetDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
// tsconfig.json
const tsconfig = {
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
declaration: true,
declarationMap: true,
strict: true,
esModuleInterop: true,
skipLibCheck: true,
outDir: './dist',
rootDir: './src',
},
include: ['src'],
exclude: ['node_modules', 'dist'],
};
fs.writeFileSync(
path.join(targetDir, 'tsconfig.json'),
JSON.stringify(tsconfig, null, 2)
);
// tsup.config.ts
const tsupConfig = `import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
});
`;
fs.writeFileSync(path.join(targetDir, 'tsup.config.ts'), tsupConfig);
// Create src directory
const srcDir = path.join(targetDir, 'src');
if (!fs.existsSync(srcDir)) {
fs.mkdirSync(srcDir, { recursive: true });
}
// src/index.ts
const indexTs = `import { jsonSchema, tool } from 'ai';
/**
* ${config.description}
*/
export const myTool = tool({
description: '${config.description}',
parameters: jsonSchema<{ input: string }>({
type: 'object',
properties: {
input: {
type: 'string',
description: 'The input to process',
},
},
required: ['input'],
}),
async execute({ input }) {
// TODO: Implement your tool logic here
return {
result: \`Processed: \${input}\`,
};
},
});
export default myTool;
`;
fs.writeFileSync(path.join(srcDir, 'index.ts'), indexTs);
// block.ts (required for TPMJS validation)
const blockTs = `import { myTool } from './src/index.js';
export const block = {
name: '${config.name}',
tools: { myTool },
};
`;
fs.writeFileSync(path.join(targetDir, 'block.ts'), blockTs);
// README.md
const readme = `# ${config.name}
${config.description}
## Installation
\`\`\`bash
npm install ${config.name}
\`\`\`
## Usage
\`\`\`typescript
import { myTool } from '${config.name}';
const result = await myTool.execute({ input: 'hello' });
console.log(result);
\`\`\`
## Category
${config.category}
## License
MIT
`;
fs.writeFileSync(path.join(targetDir, 'README.md'), readme);
// .gitignore
const gitignore = `node_modules/
dist/
*.log
.DS_Store
`;
fs.writeFileSync(path.join(targetDir, '.gitignore'), gitignore);
spinner.stop();
output.text('');
output.text('Files created:');
output.listItem('package.json');
output.listItem('tsconfig.json');
output.listItem('tsup.config.ts');
output.listItem('src/index.ts');
output.listItem('block.ts');
output.listItem('README.md');
output.listItem('.gitignore');
}
}

View file

@ -0,0 +1,136 @@
import { Args, Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class ToolSearch extends Command {
static description = 'Search for tools in the TPMJS registry';
static examples = [
'<%= config.bin %> <%= command.id %> firecrawl',
'<%= config.bin %> <%= command.id %> "web scraper" --category web',
'<%= config.bin %> <%= command.id %> --category data --limit 20',
];
static flags = {
category: Flags.string({
char: 'c',
description: 'Filter by category',
}),
limit: Flags.integer({
char: 'l',
description: 'Maximum number of results',
default: 20,
}),
offset: Flags.integer({
char: 'o',
description: 'Offset for pagination',
default: 0,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
static args = {
query: Args.string({
description: 'Search query',
required: false,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(ToolSearch);
const output = createOutput(flags);
const client = getClient();
const spinner = output.spinner('Searching tools...');
try {
const response = await client.searchTools({
query: args.query,
category: flags.category,
limit: flags.limit,
offset: flags.offset,
});
spinner.stop();
if (flags.json) {
output.json(response);
return;
}
if (response.data.length === 0) {
output.info('No tools found');
if (args.query) {
output.text(`Try a different search query or remove the --category filter`);
}
return;
}
output.table(
response.data.map((tool) => ({
name: tool.name,
package: tool.npmPackageName,
category: tool.category,
downloads: formatDownloads(tool.npmDownloadsLastMonth),
health: formatHealth(tool.importHealth, tool.executionHealth),
score: typeof tool.qualityScore === 'number' ? tool.qualityScore.toFixed(2) : '-',
})),
[
{ key: 'name', header: 'Name', width: 25 },
{ key: 'package', header: 'Package', width: 35 },
{ key: 'category', header: 'Category', width: 15 },
{ key: 'downloads', header: 'Downloads', width: 12 },
{ key: 'health', header: 'Health', width: 10 },
{ key: 'score', header: 'Score', width: 8 },
]
);
output.newLine();
output.text(
output.dim(
`Showing ${response.data.length} of ${response.pagination.hasMore ? 'more' : response.data.length} tools` +
(args.query ? ` matching "${args.query}"` : '')
)
);
if (response.pagination.hasMore) {
output.text(
output.dim(
`Use --offset ${flags.offset + flags.limit} to see more results`
)
);
}
} catch (error) {
spinner.fail('Search failed');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}
function formatDownloads(count: number | undefined): string {
if (count === undefined || count === null) return '-';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return count.toString();
}
function formatHealth(importHealth: string, executionHealth: string): string {
if (importHealth === 'BROKEN' || executionHealth === 'BROKEN') {
return 'Broken';
}
if (importHealth === 'HEALTHY' && executionHealth === 'HEALTHY') {
return 'Healthy';
}
return 'Unknown';
}

View file

@ -0,0 +1,89 @@
import { Command, Flags } from '@oclif/core';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class ToolTrending extends Command {
static description = 'Show trending tools';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --limit 10',
];
static flags = {
limit: Flags.integer({
char: 'l',
description: 'Maximum number of results',
default: 10,
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(ToolTrending);
const output = createOutput(flags);
const client = getClient();
const spinner = output.spinner('Fetching trending tools...');
try {
const response = await client.getTrendingTools({
limit: flags.limit,
});
spinner.stop();
if (flags.json) {
output.json(response);
return;
}
if (response.data.length === 0) {
output.info('No trending tools found');
return;
}
output.heading('Trending Tools');
output.table(
response.data.map((tool, index) => ({
rank: `#${index + 1}`,
name: tool.name,
package: tool.npmPackageName,
category: tool.category,
downloads: formatDownloads(tool.npmDownloadsLastMonth),
score: typeof tool.qualityScore === 'number' ? tool.qualityScore.toFixed(2) : '-',
})),
[
{ key: 'rank', header: '#', width: 4 },
{ key: 'name', header: 'Name', width: 25 },
{ key: 'package', header: 'Package', width: 35 },
{ key: 'category', header: 'Category', width: 15 },
{ key: 'downloads', header: 'Downloads', width: 12 },
{ key: 'score', header: 'Score', width: 8 },
]
);
} catch (error) {
spinner.fail('Failed to fetch trending tools');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}
function formatDownloads(count: number | undefined): string {
if (count === undefined || count === null) return '-';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return count.toString();
}

View file

@ -0,0 +1,131 @@
import { Command, Flags } from '@oclif/core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getClient } from '../../lib/api-client.js';
import { createOutput } from '../../lib/output.js';
export default class ToolValidate extends Command {
static description = 'Validate a tpmjs package configuration';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --path ./my-tool',
];
static flags = {
path: Flags.string({
char: 'p',
description: 'Path to package directory (defaults to current directory)',
default: '.',
}),
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(ToolValidate);
const output = createOutput(flags);
const client = getClient();
const packagePath = path.resolve(flags.path, 'package.json');
// Check if package.json exists
if (!fs.existsSync(packagePath)) {
output.error(`package.json not found at ${packagePath}`);
return;
}
let packageJson: Record<string, unknown>;
try {
const content = fs.readFileSync(packagePath, 'utf-8');
packageJson = JSON.parse(content);
} catch (error) {
output.error('Failed to parse package.json', error instanceof Error ? error.message : undefined);
return;
}
// Check for tpmjs keyword
const keywords = (packageJson.keywords as string[]) || [];
const hasTpmjsKeyword = keywords.includes('tpmjs');
if (!hasTpmjsKeyword) {
output.warning('Missing "tpmjs" keyword in package.json');
output.text('Add "tpmjs" to the keywords array for auto-discovery');
}
// Check for tpmjs field
const tpmjsField = packageJson.tpmjs;
if (!tpmjsField) {
output.error('Missing "tpmjs" field in package.json');
output.newLine();
output.text('Add a tpmjs field like:');
output.code(
JSON.stringify(
{
tpmjs: {
category: 'utilities',
tools: ['myTool'],
},
},
null,
2
),
'json'
);
return;
}
// Validate with API
const spinner = output.spinner('Validating tpmjs configuration...');
try {
const response = await client.validateTpmjsField(tpmjsField);
spinner.stop();
if (flags.json) {
output.json({
valid: response.data?.valid ?? false,
tier: response.data?.tier,
errors: response.data?.errors,
hasTpmjsKeyword,
});
return;
}
if (response.data?.valid) {
output.success('Configuration is valid');
output.newLine();
output.keyValue('Tier', response.data.tier || 'minimal');
output.keyValue('Has tpmjs keyword', hasTpmjsKeyword ? 'Yes' : 'No (add for auto-discovery)');
if (!hasTpmjsKeyword) {
output.newLine();
output.warning('Add "tpmjs" to keywords for auto-discovery on npm publish');
}
} else {
output.error('Configuration is invalid');
if (response.data?.errors && Array.isArray(response.data.errors)) {
output.newLine();
output.subheading('Errors:');
for (const error of response.data.errors) {
output.listItem(JSON.stringify(error));
}
}
}
} catch (error) {
spinner.fail('Validation failed');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,93 @@
import { Command, Flags } from '@oclif/core';
import { execSync } from 'node:child_process';
import { createOutput } from '../lib/output.js';
export default class Update extends Command {
static description = 'Update the TPMJS CLI to the latest version';
static examples = ['<%= config.bin %> <%= command.id %>'];
static flags = {
json: Flags.boolean({
description: 'Output in JSON format',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
check: Flags.boolean({
description: 'Only check for updates, do not install',
default: false,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Update);
const output = createOutput(flags);
const currentVersion = this.config.version;
output.debug(`Current version: ${currentVersion}`);
// Check for latest version on npm
const spinner = output.spinner('Checking for updates...');
try {
const latestVersion = execSync('npm view @tpmjs/cli version', {
encoding: 'utf-8',
timeout: 10000,
}).trim();
spinner.stop();
if (flags.json) {
output.json({
currentVersion,
latestVersion,
updateAvailable: latestVersion !== currentVersion,
});
return;
}
if (latestVersion === currentVersion) {
output.success(`You're on the latest version (${currentVersion})`);
return;
}
output.info(`Update available: ${currentVersion}${latestVersion}`);
if (flags.check) {
output.text('Run `tpm update` to install the update');
return;
}
// Perform update
const updateSpinner = output.spinner('Installing update...');
try {
// Try npm first, then pnpm, then yarn
execSync('npm install -g @tpmjs/cli@latest', {
encoding: 'utf-8',
timeout: 120000,
stdio: flags.verbose ? 'inherit' : 'pipe',
});
updateSpinner.succeed(`Updated to ${latestVersion}`);
output.text('Restart your terminal to use the new version');
} catch (installError) {
updateSpinner.fail('Update failed');
output.error(
'Failed to install update',
flags.verbose ? String(installError) : 'Try running: npm install -g @tpmjs/cli@latest'
);
}
} catch (error) {
spinner.fail('Failed to check for updates');
output.error(
error instanceof Error ? error.message : 'Unknown error',
flags.verbose ? String(error) : undefined
);
}
}
}

View file

@ -0,0 +1,12 @@
import type { Hook } from '@oclif/core';
const hook: Hook<'init'> = async function () {
// Initialization hook - runs before any command
// Can be used for:
// - Checking for updates
// - Loading config
// - Setting up analytics (if opted in)
// - etc.
};
export default hook;

36
packages/cli/src/index.ts Normal file
View file

@ -0,0 +1,36 @@
// Library exports for programmatic use
export { TpmClient, getClient, ApiError } from './lib/api-client.js';
export type {
TpmClientOptions,
ApiResponse,
PaginationOptions,
PaginatedResponse,
Tool,
ToolSearchOptions,
Agent,
CreateAgentInput,
UpdateAgentInput,
Collection,
CreateCollectionInput,
UpdateCollectionInput,
User,
ApiKey,
Stats,
} from './lib/api-client.js';
export {
getConfig,
setConfig,
getConfigValue,
setConfigValue,
loadCredentials,
saveCredentials,
deleteCredentials,
hasCredentials,
getApiKey,
getApiUrl,
} from './lib/config.js';
export type { TpmConfig, TpmCredentials } from './lib/config.js';
export { OutputFormatter, createOutput } from './lib/output.js';
export type { OutputOptions } from './lib/output.js';

View file

@ -0,0 +1,470 @@
import { getApiKey, getApiUrl } from './config.js';
export interface TpmClientOptions {
baseUrl?: string;
apiKey?: string;
timeout?: number;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface PaginationOptions {
limit?: number;
offset?: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
limit: number;
offset: number;
hasMore: boolean;
};
}
// Tool types
export interface Tool {
id: string;
name: string;
slug: string;
description: string;
category: string;
tier: string;
qualityScore: number | null;
importHealth: string;
executionHealth: string;
likeCount: number;
npmPackageName: string;
npmVersion: string;
npmDownloadsLastMonth: number;
isOfficial: boolean;
inputSchema?: Record<string, unknown>;
outputSchema?: Record<string, unknown>;
tools?: { name: string; description?: string }[];
package?: {
npmPackageName: string;
category: string;
npmDownloadsLastMonth: number;
isOfficial: boolean;
};
}
export interface ToolSearchOptions extends PaginationOptions {
category?: string;
query?: string;
}
// Agent types
export interface Agent {
id: string;
uid: string;
name: string;
description: string | null;
provider: string;
modelId: string;
systemPrompt: string | null;
temperature: number;
isPublic: boolean;
likeCount: number;
_count?: {
tools: number;
collections: number;
};
}
export interface CreateAgentInput {
name: string;
uid?: string;
description?: string;
provider: string;
modelId: string;
systemPrompt?: string;
temperature?: number;
isPublic?: boolean;
collectionIds?: string[];
toolIds?: string[];
}
export interface UpdateAgentInput {
name?: string;
uid?: string;
description?: string;
provider?: string;
modelId?: string;
systemPrompt?: string;
temperature?: number;
isPublic?: boolean;
maxToolCallsPerTurn?: number;
maxMessagesInContext?: number;
}
// Collection types
export interface Collection {
id: string;
name: string;
slug: string | null;
description: string | null;
isPublic: boolean;
likeCount: number;
_count?: {
tools: number;
};
}
export interface CreateCollectionInput {
name: string;
description?: string;
isPublic: boolean;
}
export interface UpdateCollectionInput {
name?: string;
description?: string;
isPublic?: boolean;
}
// User types
export interface User {
id: string;
name: string | null;
username: string | null;
email: string;
image: string | null;
}
// API Key types
export interface ApiKey {
id: string;
name: string;
keyPrefix: string;
scopes: string[];
isActive: boolean;
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
}
// Stats types
export interface Stats {
tools: {
total: number;
official: number;
healthyImport: number;
healthyExecution: number;
};
packages: {
total: number;
official: number;
};
categories: { name: string; count: number }[];
}
export class TpmClient {
private baseUrl: string;
private apiKey: string | undefined;
private timeout: number;
constructor(options: TpmClientOptions = {}) {
this.baseUrl = options.baseUrl ?? getApiUrl();
this.apiKey = options.apiKey ?? getApiKey();
this.timeout = options.timeout ?? 30000;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
...options,
headers,
signal: controller.signal,
});
const data = await response.json() as T & { message?: string; error?: string };
if (!response.ok) {
throw new ApiError(
data.message || data.error || `HTTP ${response.status}`,
response.status,
data
);
}
return data as T;
} finally {
clearTimeout(timeoutId);
}
}
// Health check
async health(): Promise<{ status: string; timestamp: string }> {
return this.request('/health');
}
// Stats
async getStats(): Promise<ApiResponse<Stats>> {
return this.request('/stats');
}
// Tools
async searchTools(options: ToolSearchOptions = {}): Promise<PaginatedResponse<Tool>> {
const params = new URLSearchParams();
if (options.query) params.set('q', options.query);
if (options.category) params.set('category', options.category);
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
const queryString = params.toString();
const endpoint = queryString ? `/tools?${queryString}` : '/tools';
return this.request<PaginatedResponse<Tool>>(endpoint);
}
async getTool(packageName: string, toolName: string): Promise<ApiResponse<Tool>> {
return this.request(`/tools/${encodeURIComponent(packageName)}/${encodeURIComponent(toolName)}`);
}
async getToolBySlug(slug: string): Promise<ApiResponse<Tool>> {
// Search for the tool by slug
const searchResult = await this.searchTools({ query: slug, limit: 1 });
if (searchResult.data && searchResult.data.length > 0) {
const tool = searchResult.data.find(t => t.slug === slug) || searchResult.data[0];
return { success: true, data: tool };
}
return { success: false, error: 'Tool not found' };
}
async getTrendingTools(options: PaginationOptions = {}): Promise<PaginatedResponse<Tool>> {
const params = new URLSearchParams();
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
const queryString = params.toString();
const endpoint = queryString ? `/tools/trending?${queryString}` : '/tools/trending';
return this.request<PaginatedResponse<Tool>>(endpoint);
}
async validateTpmjsField(field: unknown): Promise<ApiResponse<{ valid: boolean; tier: string | null; errors?: unknown[] }>> {
return this.request('/tools/validate', {
method: 'POST',
body: JSON.stringify(field),
});
}
async executeTool(slug: string, params: Record<string, unknown>): Promise<unknown> {
return this.request(`/tools/${encodeURIComponent(slug)}/execute`, {
method: 'POST',
body: JSON.stringify(params),
});
}
async *executeToolStream(
slug: string,
params: Record<string, unknown>
): AsyncGenerator<{ type: string; data: string }> {
const url = `${this.baseUrl}/tools/${encodeURIComponent(slug)}/execute`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
};
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ ...params, stream: true }),
});
if (!response.ok) {
const errorText = await response.text();
throw new ApiError(errorText || `HTTP ${response.status}`, response.status);
}
if (!response.body) {
throw new ApiError('No response body', 0);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
yield { type: 'done', data: '' };
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
yield { type: 'done', data: '' };
return;
}
try {
const parsed = JSON.parse(data);
yield { type: parsed.type || 'text', data: parsed.content || parsed.data || data };
} catch {
yield { type: 'text', data };
}
}
}
}
} finally {
reader.releaseLock();
}
}
// Agents
async listAgents(options: PaginationOptions = {}): Promise<PaginatedResponse<Agent>> {
const params = new URLSearchParams();
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
const queryString = params.toString();
const endpoint = queryString ? `/agents?${queryString}` : '/agents';
return this.request<PaginatedResponse<Agent>>(endpoint);
}
async getAgent(id: string): Promise<ApiResponse<Agent>> {
return this.request(`/agents/${id}`);
}
async createAgent(input: CreateAgentInput): Promise<ApiResponse<Agent>> {
return this.request('/agents', {
method: 'POST',
body: JSON.stringify(input),
});
}
async updateAgent(id: string, input: UpdateAgentInput): Promise<ApiResponse<Agent>> {
return this.request(`/agents/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
}
async deleteAgent(id: string): Promise<ApiResponse<void>> {
return this.request(`/agents/${id}`, {
method: 'DELETE',
});
}
// Collections
async listCollections(options: PaginationOptions = {}): Promise<PaginatedResponse<Collection>> {
const params = new URLSearchParams();
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
const queryString = params.toString();
const endpoint = queryString ? `/collections?${queryString}` : '/collections';
return this.request<PaginatedResponse<Collection>>(endpoint);
}
async getCollection(id: string): Promise<ApiResponse<Collection>> {
return this.request(`/collections/${id}`);
}
async createCollection(input: CreateCollectionInput): Promise<ApiResponse<Collection>> {
return this.request('/collections', {
method: 'POST',
body: JSON.stringify(input),
});
}
async updateCollection(id: string, input: UpdateCollectionInput): Promise<ApiResponse<Collection>> {
return this.request(`/collections/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
}
async deleteCollection(id: string): Promise<ApiResponse<void>> {
return this.request(`/collections/${id}`, {
method: 'DELETE',
});
}
async addToolsToCollection(id: string, toolIds: string[]): Promise<ApiResponse<void>> {
// Add tools one by one (API doesn't support bulk)
for (const toolId of toolIds) {
await this.request(`/collections/${id}/tools/${toolId}`, {
method: 'POST',
});
}
return { success: true };
}
async removeToolFromCollection(id: string, toolId: string): Promise<ApiResponse<void>> {
return this.request(`/collections/${id}/tools/${toolId}`, {
method: 'DELETE',
});
}
// User
async whoami(): Promise<ApiResponse<User>> {
return this.request('/user/profile');
}
async listApiKeys(): Promise<ApiResponse<ApiKey[]>> {
return this.request('/user/tpmjs-api-keys');
}
// Check if authenticated
isAuthenticated(): boolean {
return !!this.apiKey;
}
}
// Custom error class for API errors
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
// Singleton instance
let clientInstance: TpmClient | null = null;
export function getClient(options?: TpmClientOptions): TpmClient {
if (!clientInstance || options) {
clientInstance = new TpmClient(options);
}
return clientInstance;
}

View file

@ -0,0 +1,138 @@
import Conf from 'conf';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
export interface TpmConfig {
apiUrl?: string;
defaultOutput?: 'human' | 'json';
verbose?: boolean;
analytics?: boolean;
env?: Record<string, string>;
}
export interface TpmCredentials {
apiKey?: string;
refreshToken?: string;
expiresAt?: string;
}
const CONFIG_DIR = path.join(os.homedir(), '.tpmjs');
const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
const HISTORY_DIR = path.join(CONFIG_DIR, 'history');
// Ensure config directory exists
function ensureConfigDir(): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
}
}
// Config store using Conf
const configStore = new Conf<TpmConfig>({
projectName: 'tpmjs',
cwd: CONFIG_DIR,
configName: 'config',
defaults: {
apiUrl: 'https://tpmjs.com/api',
defaultOutput: 'human',
verbose: false,
analytics: false,
},
});
export function getConfig(): TpmConfig {
return configStore.store;
}
export function setConfig(config: Partial<TpmConfig>): void {
for (const [key, value] of Object.entries(config)) {
if (value !== undefined) {
configStore.set(key, value);
}
}
}
export function getConfigValue<K extends keyof TpmConfig>(key: K): TpmConfig[K] {
return configStore.get(key);
}
export function setConfigValue<K extends keyof TpmConfig>(
key: K,
value: TpmConfig[K]
): void {
configStore.set(key, value);
}
export function resetConfig(): void {
configStore.clear();
}
// Credentials management with secure file permissions
export function loadCredentials(): TpmCredentials | null {
ensureConfigDir();
if (!fs.existsSync(CREDENTIALS_FILE)) {
return null;
}
try {
const content = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
return JSON.parse(content) as TpmCredentials;
} catch {
return null;
}
}
export function saveCredentials(credentials: TpmCredentials): void {
ensureConfigDir();
const content = JSON.stringify(credentials, null, 2);
fs.writeFileSync(CREDENTIALS_FILE, content, { mode: 0o600 });
}
export function deleteCredentials(): void {
if (fs.existsSync(CREDENTIALS_FILE)) {
fs.unlinkSync(CREDENTIALS_FILE);
}
}
export function hasCredentials(): boolean {
const creds = loadCredentials();
return creds !== null && !!creds.apiKey;
}
// Get API key from multiple sources (priority order)
export function getApiKey(): string | undefined {
// 1. Environment variable
if (process.env.TPMJS_API_KEY) {
return process.env.TPMJS_API_KEY;
}
// 2. Credentials file
const creds = loadCredentials();
if (creds?.apiKey) {
return creds.apiKey;
}
return undefined;
}
// Get API URL
export function getApiUrl(): string {
return process.env.TPMJS_API_URL ?? getConfigValue('apiUrl') ?? 'https://tpmjs.com/api';
}
// History directory for conversation caching
export function getHistoryDir(): string {
if (!fs.existsSync(HISTORY_DIR)) {
fs.mkdirSync(HISTORY_DIR, { recursive: true, mode: 0o700 });
}
return HISTORY_DIR;
}
// Config directory path
export function getConfigDir(): string {
ensureConfigDir();
return CONFIG_DIR;
}

View file

@ -0,0 +1,184 @@
import Table from 'cli-table3';
import ora, { type Ora } from 'ora';
import pc from 'picocolors';
export interface OutputOptions {
json?: boolean;
verbose?: boolean;
noColor?: boolean;
}
export class OutputFormatter {
private options: OutputOptions;
constructor(options: OutputOptions = {}) {
this.options = options;
}
// Output as JSON
json(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
// Output a table
table<T extends Record<string, unknown>>(
data: T[],
columns: { key: keyof T; header: string; width?: number }[]
): void {
if (this.options.json) {
this.json(data);
return;
}
const table = new Table({
head: columns.map((col) => pc.bold(col.header)),
colWidths: columns.map((col) => col.width ?? null),
style: {
head: [],
border: [],
},
});
for (const row of data) {
table.push(columns.map((col) => String(row[col.key] ?? '')));
}
console.log(table.toString());
}
// Success message
success(message: string): void {
if (this.options.json) return;
console.log(pc.green('✓'), message);
}
// Error message
error(message: string, details?: string): void {
if (this.options.json) {
this.json({ error: message, details });
return;
}
console.error(pc.red('✗'), message);
if (details && this.options.verbose) {
console.error(pc.dim(details));
}
}
// Warning message
warning(message: string): void {
if (this.options.json) return;
console.log(pc.yellow('⚠'), message);
}
// Info message
info(message: string): void {
if (this.options.json) return;
console.log(pc.blue(''), message);
}
// Debug message (only in verbose mode)
debug(message: string): void {
if (this.options.json) return;
if (this.options.verbose) {
console.log(pc.dim(`[debug] ${message}`));
}
}
// Plain text output
text(message: string): void {
if (this.options.json) return;
console.log(message);
}
// Heading
heading(text: string): void {
if (this.options.json) return;
console.log();
console.log(pc.bold(pc.underline(text)));
console.log();
}
// Subheading
subheading(text: string): void {
if (this.options.json) return;
console.log(pc.bold(text));
}
// Key-value pair
keyValue(key: string, value: string | number | boolean | undefined): void {
if (this.options.json) return;
console.log(`${pc.dim(key + ':')} ${value ?? pc.dim('(not set)')}`);
}
// List item
listItem(text: string, indent = 0): void {
if (this.options.json) return;
const prefix = ' '.repeat(indent) + '•';
console.log(`${prefix} ${text}`);
}
// Spinner
spinner(message: string): Ora {
return ora({
text: message,
isSilent: this.options.json,
}).start();
}
// Blank line
newLine(): void {
if (this.options.json) return;
console.log();
}
// Horizontal rule
hr(): void {
if (this.options.json) return;
console.log(pc.dim('─'.repeat(50)));
}
// Alias for hr
divider(): void {
this.hr();
}
// Code block
code(text: string, language?: string): void {
if (this.options.json) {
this.json({ code: text, language });
return;
}
console.log(pc.dim('```' + (language ?? '')));
console.log(text);
console.log(pc.dim('```'));
}
// Highlight text
highlight(text: string): string {
return pc.cyan(text);
}
// Dim text
dim(text: string): string {
return pc.dim(text);
}
// Bold text
bold(text: string): string {
return pc.bold(text);
}
// Link (just returns text in terminal)
link(text: string, url: string): string {
// OSC 8 hyperlink support for modern terminals
return `\x1b]8;;${url}\x07${pc.underline(pc.blue(text))}\x1b]8;;\x07`;
}
}
// Convenience function to create formatter from command flags
export function createOutput(flags: { json?: boolean; verbose?: boolean }): OutputFormatter {
return new OutputFormatter({
json: flags.json,
verbose: flags.verbose,
});
}

View file

@ -0,0 +1,11 @@
{
"extends": "@tpmjs/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"incremental": false,
"composite": false
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,18 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: [
'src/index.ts',
'src/commands/**/*.ts',
'src/hooks/**/*.ts',
],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
splitting: false,
treeshake: true,
outDir: 'dist',
target: 'node18',
shims: true,
});