diff --git a/apps/cli/src/commands/agent.test.ts b/apps/cli/src/commands/agent.test.ts new file mode 100644 index 0000000000..ddaf8edab5 --- /dev/null +++ b/apps/cli/src/commands/agent.test.ts @@ -0,0 +1,189 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerAgentCommand } from './agent'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + agent: { + createAgent: { mutate: vi.fn() }, + duplicateAgent: { mutate: vi.fn() }, + getAgentConfigById: { query: vi.fn() }, + queryAgents: { query: vi.fn() }, + removeAgent: { mutate: vi.fn() }, + updateAgentConfig: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('agent command', () => { + let exitSpy: ReturnType; + let consoleSpy: ReturnType; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.agent)) { + for (const fn of Object.values(method)) { + (fn as ReturnType).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerAgentCommand(program); + return program; + } + + describe('list', () => { + it('should display agents in table format', async () => { + mockTrpcClient.agent.queryAgents.query.mockResolvedValue([ + { id: 'a1', model: 'gpt-4', title: 'My Agent' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); // header + row + }); + + it('should filter by keyword', async () => { + mockTrpcClient.agent.queryAgents.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'list', '-k', 'test']); + + expect(mockTrpcClient.agent.queryAgents.query).toHaveBeenCalledWith( + expect.objectContaining({ keyword: 'test' }), + ); + }); + + it('should output JSON', async () => { + const agents = [{ id: 'a1', title: 'Test' }]; + mockTrpcClient.agent.queryAgents.query.mockResolvedValue(agents); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'list', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(agents, null, 2)); + }); + }); + + describe('view', () => { + it('should display agent config', async () => { + mockTrpcClient.agent.getAgentConfigById.query.mockResolvedValue({ + model: 'gpt-4', + systemRole: 'You are helpful.', + title: 'Test Agent', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'view', 'a1']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test Agent')); + }); + + it('should exit when not found', async () => { + mockTrpcClient.agent.getAgentConfigById.query.mockResolvedValue(null); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'view', 'nonexistent']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('create', () => { + it('should create an agent', async () => { + mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ + agentId: 'a-new', + sessionId: 's1', + }); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'agent', + 'create', + '--title', + 'My Agent', + '--model', + 'gpt-4', + ]); + + expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ model: 'gpt-4', title: 'My Agent' }), + }), + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('a-new')); + }); + }); + + describe('edit', () => { + it('should update agent config', async () => { + mockTrpcClient.agent.updateAgentConfig.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'edit', 'a1', '--title', 'Updated']); + + expect(mockTrpcClient.agent.updateAgentConfig.mutate).toHaveBeenCalledWith({ + agentId: 'a1', + value: { title: 'Updated' }, + }); + }); + + it('should exit when no changes specified', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'edit', 'a1']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('delete', () => { + it('should delete with --yes', async () => { + mockTrpcClient.agent.removeAgent.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'delete', 'a1', '--yes']); + + expect(mockTrpcClient.agent.removeAgent.mutate).toHaveBeenCalledWith({ agentId: 'a1' }); + }); + }); + + describe('duplicate', () => { + it('should duplicate an agent', async () => { + mockTrpcClient.agent.duplicateAgent.mutate.mockResolvedValue({ agentId: 'a-dup' }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'agent', 'duplicate', 'a1', '--title', 'Copy']); + + expect(mockTrpcClient.agent.duplicateAgent.mutate).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'a1', newTitle: 'Copy' }), + ); + }); + }); +}); diff --git a/apps/cli/src/commands/agent.ts b/apps/cli/src/commands/agent.ts new file mode 100644 index 0000000000..50a3ed2c3d --- /dev/null +++ b/apps/cli/src/commands/agent.ts @@ -0,0 +1,202 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerAgentCommand(program: Command) { + const agent = program.command('agent').description('Manage agents'); + + // ── list ────────────────────────────────────────────── + + agent + .command('list') + .description('List agents') + .option('-L, --limit ', 'Maximum number of items', '30') + .option('-k, --keyword ', 'Filter by keyword') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean; keyword?: string; limit?: string }) => { + const client = await getTrpcClient(); + + const input: { keyword?: string; limit?: number; offset?: number } = {}; + if (options.keyword) input.keyword = options.keyword; + if (options.limit) input.limit = Number.parseInt(options.limit, 10); + + const result = await client.agent.queryAgents.query(input); + const items = Array.isArray(result) ? result : ((result as any).items ?? []); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No agents found.'); + return; + } + + const rows = items.map((a: any) => [ + a.id || a.agentId || '', + truncate(a.title || a.name || a.meta?.title || 'Untitled', 40), + truncate(a.description || a.meta?.description || '', 50), + a.model || '', + ]); + + printTable(rows, ['ID', 'TITLE', 'DESCRIPTION', 'MODEL']); + }); + + // ── view ────────────────────────────────────────────── + + agent + .command('view ') + .description('View agent configuration') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (agentId: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.agent.getAgentConfigById.query({ agentId }); + + if (!result) { + log.error(`Agent not found: ${agentId}`); + process.exit(1); + return; + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + const r = result as any; + console.log(pc.bold(r.title || r.meta?.title || 'Untitled')); + const meta: string[] = []; + if (r.description || r.meta?.description) meta.push(r.description || r.meta.description); + if (r.model) meta.push(`Model: ${r.model}`); + if (r.provider) meta.push(`Provider: ${r.provider}`); + if (meta.length > 0) console.log(pc.dim(meta.join(' · '))); + + if (r.systemRole) { + console.log(); + console.log(pc.bold('System Role:')); + console.log(r.systemRole); + } + }); + + // ── create ──────────────────────────────────────────── + + agent + .command('create') + .description('Create a new agent') + .option('-t, --title ', 'Agent title') + .option('-d, --description <desc>', 'Agent description') + .option('-m, --model <model>', 'Model ID') + .option('-p, --provider <provider>', 'Provider ID') + .option('-s, --system-role <role>', 'System role prompt') + .option('--group <groupId>', 'Group ID') + .action( + async (options: { + description?: string; + group?: string; + model?: string; + provider?: string; + systemRole?: string; + title?: string; + }) => { + const client = await getTrpcClient(); + + const config: Record<string, any> = {}; + if (options.title) config.title = options.title; + if (options.description) config.description = options.description; + if (options.model) config.model = options.model; + if (options.provider) config.provider = options.provider; + if (options.systemRole) config.systemRole = options.systemRole; + + const input: Record<string, any> = { config }; + if (options.group) input.groupId = options.group; + + const result = await client.agent.createAgent.mutate(input as any); + const r = result as any; + console.log(`${pc.green('✓')} Created agent ${pc.bold(r.agentId || r.id)}`); + if (r.sessionId) console.log(` Session: ${r.sessionId}`); + }, + ); + + // ── edit ────────────────────────────────────────────── + + agent + .command('edit <agentId>') + .description('Update agent configuration') + .option('-t, --title <title>', 'New title') + .option('-d, --description <desc>', 'New description') + .option('-m, --model <model>', 'New model ID') + .option('-p, --provider <provider>', 'New provider ID') + .option('-s, --system-role <role>', 'New system role prompt') + .action( + async ( + agentId: string, + options: { + description?: string; + model?: string; + provider?: string; + systemRole?: string; + title?: string; + }, + ) => { + const value: Record<string, any> = {}; + if (options.title) value.title = options.title; + if (options.description) value.description = options.description; + if (options.model) value.model = options.model; + if (options.provider) value.provider = options.provider; + if (options.systemRole) value.systemRole = options.systemRole; + + if (Object.keys(value).length === 0) { + log.error( + 'No changes specified. Use --title, --description, --model, --provider, or --system-role.', + ); + process.exit(1); + } + + const client = await getTrpcClient(); + await client.agent.updateAgentConfig.mutate({ agentId, value }); + console.log(`${pc.green('✓')} Updated agent ${pc.bold(agentId)}`); + }, + ); + + // ── delete ──────────────────────────────────────────── + + agent + .command('delete <agentId>') + .description('Delete an agent') + .option('--yes', 'Skip confirmation prompt') + .action(async (agentId: string, options: { yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm('Are you sure you want to delete this agent?'); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + await client.agent.removeAgent.mutate({ agentId }); + console.log(`${pc.green('✓')} Deleted agent ${pc.bold(agentId)}`); + }); + + // ── duplicate ───────────────────────────────────────── + + agent + .command('duplicate <agentId>') + .description('Duplicate an agent') + .option('-t, --title <title>', 'Title for the duplicate') + .action(async (agentId: string, options: { title?: string }) => { + const client = await getTrpcClient(); + const input: Record<string, any> = { agentId }; + if (options.title) input.newTitle = options.title; + + const result = await client.agent.duplicateAgent.mutate(input as any); + const r = result as any; + console.log(`${pc.green('✓')} Duplicated agent → ${pc.bold(r.agentId || r.id || 'done')}`); + }); +} diff --git a/apps/cli/src/commands/config.test.ts b/apps/cli/src/commands/config.test.ts new file mode 100644 index 0000000000..8457ab4f44 --- /dev/null +++ b/apps/cli/src/commands/config.test.ts @@ -0,0 +1,117 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerConfigCommand } from './config'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + usage: { + findAndGroupByDay: { query: vi.fn() }, + findByMonth: { query: vi.fn() }, + }, + user: { + getUserState: { query: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('config command', () => { + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + mockTrpcClient.user.getUserState.query.mockReset(); + mockTrpcClient.usage.findByMonth.query.mockReset(); + mockTrpcClient.usage.findAndGroupByDay.query.mockReset(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerConfigCommand(program); + return program; + } + + describe('whoami', () => { + it('should display user info', async () => { + mockTrpcClient.user.getUserState.query.mockResolvedValue({ + email: 'test@example.com', + fullName: 'Test User', + userId: 'u1', + username: 'testuser', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'whoami']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test User')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('testuser')); + }); + + it('should output JSON', async () => { + const state = { email: 'test@example.com', userId: 'u1' }; + mockTrpcClient.user.getUserState.query.mockResolvedValue(state); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'whoami', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(state, null, 2)); + }); + }); + + describe('usage', () => { + it('should display monthly usage', async () => { + mockTrpcClient.usage.findByMonth.query.mockResolvedValue({ totalTokens: 1000 }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'usage']); + + expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalled(); + }); + + it('should display daily usage', async () => { + mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([ + { date: '2024-01-01', totalTokens: 100 }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'usage', '--daily']); + + expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalled(); + }); + + it('should pass month param', async () => { + mockTrpcClient.usage.findByMonth.query.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'usage', '--month', '2024-01']); + + expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalledWith({ mo: '2024-01' }); + }); + + it('should output JSON', async () => { + const data = { totalTokens: 1000 }; + mockTrpcClient.usage.findByMonth.query.mockResolvedValue(data); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'usage', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); + }); + }); +}); diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts new file mode 100644 index 0000000000..61f0653939 --- /dev/null +++ b/apps/cli/src/commands/config.ts @@ -0,0 +1,78 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { outputJson } from '../utils/format'; + +export function registerConfigCommand(program: Command) { + // ── whoami ──────────────────────────────────────────── + + program + .command('whoami') + .description('Display current user information') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const state = await client.user.getUserState.query(); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(state, fields); + return; + } + + const s = state as any; + console.log(pc.bold('User Info')); + if (s.fullName || s.firstName) console.log(` Name: ${s.fullName || s.firstName}`); + if (s.username) console.log(` Username: ${s.username}`); + if (s.email) console.log(` Email: ${s.email}`); + if (s.userId) console.log(` User ID: ${s.userId}`); + if (s.subscriptionPlan) console.log(` Plan: ${s.subscriptionPlan}`); + }); + + // ── usage ───────────────────────────────────────────── + + program + .command('usage') + .description('View usage statistics') + .option('--month <YYYY-MM>', 'Month to query (default: current)') + .option('--daily', 'Group by day') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { daily?: boolean; json?: string | boolean; month?: string }) => { + const client = await getTrpcClient(); + + const input: { mo?: string } = {}; + if (options.month) input.mo = options.month; + + let result: any; + if (options.daily) { + result = await client.usage.findAndGroupByDay.query(input); + } else { + result = await client.usage.findByMonth.query(input); + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + if (!result) { + console.log('No usage data available.'); + return; + } + + if (options.daily && Array.isArray(result)) { + console.log(pc.bold('Daily Usage')); + for (const entry of result) { + const e = entry as any; + const day = e.date || e.day || ''; + const tokens = e.totalTokens || e.tokens || 0; + console.log(` ${day}: ${tokens} tokens`); + } + } else { + console.log(pc.bold('Monthly Usage')); + console.log(JSON.stringify(result, null, 2)); + } + }); +} diff --git a/apps/cli/src/commands/doc.ts b/apps/cli/src/commands/doc.ts index 1d64ac0554..7b8cd58519 100644 --- a/apps/cli/src/commands/doc.ts +++ b/apps/cli/src/commands/doc.ts @@ -1,74 +1,14 @@ import fs from 'node:fs'; -import readline from 'node:readline'; import type { Command } from 'commander'; import pc from 'picocolors'; import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format'; import { log } from '../utils/logger'; // ── Helpers ──────────────────────────────────────────────── -function timeAgo(date: Date | string): string { - const diff = Date.now() - new Date(date).getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) return `${days}d ago`; - if (hours > 0) return `${hours}h ago`; - if (minutes > 0) return `${minutes}m ago`; - return `${seconds}s ago`; -} - -function truncate(str: string, len: number): string { - if (str.length <= len) return str; - return str.slice(0, len - 1) + '…'; -} - -function printTable(rows: string[][], header: string[]) { - const allRows = [header, ...rows]; - const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => (r[i] || '').length))); - - // Header - const headerLine = header.map((h, i) => h.padEnd(colWidths[i])).join(' '); - console.log(pc.bold(headerLine)); - - // Rows - for (const row of rows) { - const line = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' '); - console.log(line); - } -} - -function pickFields(obj: Record<string, any>, fields: string[]): Record<string, any> { - const result: Record<string, any> = {}; - for (const f of fields) { - if (f in obj) result[f] = obj[f]; - } - return result; -} - -function outputJson(data: unknown, fields?: string) { - if (fields) { - const fieldList = fields.split(',').map((f) => f.trim()); - if (Array.isArray(data)) { - console.log( - JSON.stringify( - data.map((item) => pickFields(item, fieldList)), - null, - 2, - ), - ); - } else if (data && typeof data === 'object') { - console.log(JSON.stringify(pickFields(data as Record<string, any>, fieldList), null, 2)); - } - } else { - console.log(JSON.stringify(data, null, 2)); - } -} - function readBodyContent(options: { body?: string; bodyFile?: string }): string | undefined { if (options.bodyFile) { if (!fs.existsSync(options.bodyFile)) { @@ -80,16 +20,6 @@ function readBodyContent(options: { body?: string; bodyFile?: string }): string return options.body; } -async function confirm(message: string): Promise<boolean> { - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - return new Promise((resolve) => { - rl.question(`${message} (y/N) `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y'); - }); - }); -} - // ── Command Registration ─────────────────────────────────── export function registerDocCommand(program: Command) { diff --git a/apps/cli/src/commands/file.test.ts b/apps/cli/src/commands/file.test.ts new file mode 100644 index 0000000000..5b97ce9d70 --- /dev/null +++ b/apps/cli/src/commands/file.test.ts @@ -0,0 +1,177 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerFileCommand } from './file'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + file: { + getFileItemById: { query: vi.fn() }, + getFiles: { query: vi.fn() }, + recentFiles: { query: vi.fn() }, + removeFile: { mutate: vi.fn() }, + removeFiles: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('file command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.file)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerFileCommand(program); + return program; + } + + describe('list', () => { + it('should display files in table format', async () => { + mockTrpcClient.file.getFiles.query.mockResolvedValue([ + { + fileType: 'pdf', + id: 'f1', + name: 'doc.pdf', + size: 2048, + updatedAt: new Date().toISOString(), + }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row + expect(consoleSpy.mock.calls[0][0]).toContain('ID'); + }); + + it('should output JSON when --json flag is used', async () => { + const items = [{ id: 'f1', name: 'doc.pdf' }]; + mockTrpcClient.file.getFiles.query.mockResolvedValue(items); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'list', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2)); + }); + + it('should show message when no files found', async () => { + mockTrpcClient.file.getFiles.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'list']); + + expect(consoleSpy).toHaveBeenCalledWith('No files found.'); + }); + + it('should filter by knowledge base ID', async () => { + mockTrpcClient.file.getFiles.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'list', '--kb-id', 'kb1']); + + expect(mockTrpcClient.file.getFiles.query).toHaveBeenCalledWith( + expect.objectContaining({ knowledgeBaseId: 'kb1' }), + ); + }); + }); + + describe('view', () => { + it('should display file details', async () => { + mockTrpcClient.file.getFileItemById.query.mockResolvedValue({ + fileType: 'pdf', + id: 'f1', + name: 'doc.pdf', + size: 2048, + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'view', 'f1']); + + expect(mockTrpcClient.file.getFileItemById.query).toHaveBeenCalledWith({ id: 'f1' }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('doc.pdf')); + }); + + it('should exit when not found', async () => { + mockTrpcClient.file.getFileItemById.query.mockResolvedValue(null); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'view', 'nonexistent']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('delete', () => { + it('should delete a single file with --yes', async () => { + mockTrpcClient.file.removeFile.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'delete', 'f1', '--yes']); + + expect(mockTrpcClient.file.removeFile.mutate).toHaveBeenCalledWith({ id: 'f1' }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted')); + }); + + it('should delete multiple files with --yes', async () => { + mockTrpcClient.file.removeFiles.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'delete', 'f1', 'f2', '--yes']); + + expect(mockTrpcClient.file.removeFiles.mutate).toHaveBeenCalledWith({ ids: ['f1', 'f2'] }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted 2')); + }); + }); + + describe('recent', () => { + it('should list recent files', async () => { + mockTrpcClient.file.recentFiles.query.mockResolvedValue([ + { fileType: 'pdf', id: 'f1', name: 'doc.pdf', updatedAt: new Date().toISOString() }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'recent']); + + expect(mockTrpcClient.file.recentFiles.query).toHaveBeenCalledWith({ limit: 10 }); + expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row + }); + + it('should show message when no recent files', async () => { + mockTrpcClient.file.recentFiles.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'file', 'recent']); + + expect(consoleSpy).toHaveBeenCalledWith('No recent files.'); + }); + }); +}); diff --git a/apps/cli/src/commands/file.ts b/apps/cli/src/commands/file.ts new file mode 100644 index 0000000000..802f974a65 --- /dev/null +++ b/apps/cli/src/commands/file.ts @@ -0,0 +1,147 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerFileCommand(program: Command) { + const file = program.command('file').description('Manage files'); + + // ── list ────────────────────────────────────────────── + + file + .command('list') + .description('List files') + .option('--kb-id <id>', 'Filter by knowledge base ID') + .option('-L, --limit <n>', 'Maximum number of items', '30') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean; kbId?: string; limit?: string }) => { + const client = await getTrpcClient(); + const input: any = {}; + if (options.kbId) input.knowledgeBaseId = options.kbId; + if (options.limit) input.limit = Number.parseInt(options.limit, 10); + + const result = await client.file.getFiles.query(input); + const items = Array.isArray(result) ? result : ((result as any).items ?? []); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No files found.'); + return; + } + + const rows = items.map((f: any) => [ + f.id, + truncate(f.name || f.filename || '', 50), + f.fileType || '', + f.size ? `${Math.round(f.size / 1024)}KB` : '', + f.updatedAt ? timeAgo(f.updatedAt) : '', + ]); + + printTable(rows, ['ID', 'NAME', 'TYPE', 'SIZE', 'UPDATED']); + }); + + // ── view ────────────────────────────────────────────── + + file + .command('view <id>') + .description('View file details') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (id: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.file.getFileItemById.query({ id }); + + if (!result) { + log.error(`File not found: ${id}`); + process.exit(1); + return; + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + const r = result as any; + console.log(pc.bold(r.name || r.filename || 'Unknown')); + const meta: string[] = []; + if (r.fileType) meta.push(r.fileType); + if (r.size) meta.push(`${Math.round(r.size / 1024)}KB`); + if (r.updatedAt) meta.push(`Updated ${timeAgo(r.updatedAt)}`); + if (meta.length > 0) console.log(pc.dim(meta.join(' · '))); + + if (r.chunkingStatus || r.embeddingStatus) { + console.log(); + if (r.chunkingStatus) console.log(` Chunking: ${r.chunkingStatus}`); + if (r.embeddingStatus) console.log(` Embedding: ${r.embeddingStatus}`); + } + }); + + // ── delete ──────────────────────────────────────────── + + file + .command('delete <ids...>') + .description('Delete one or more files') + .option('--yes', 'Skip confirmation prompt') + .action(async (ids: string[], options: { yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm(`Are you sure you want to delete ${ids.length} file(s)?`); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + + if (ids.length === 1) { + await client.file.removeFile.mutate({ id: ids[0] }); + } else { + await client.file.removeFiles.mutate({ ids }); + } + + console.log(`${pc.green('✓')} Deleted ${ids.length} file(s)`); + }); + + // ── recent ──────────────────────────────────────────── + + file + .command('recent') + .description('List recently accessed files') + .option('-L, --limit <n>', 'Number of items', '10') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean; limit?: string }) => { + const client = await getTrpcClient(); + const limit = Number.parseInt(options.limit || '10', 10); + + const result = await client.file.recentFiles.query({ limit }); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No recent files.'); + return; + } + + const rows = items.map((f: any) => [ + f.id, + truncate(f.name || f.filename || '', 50), + f.fileType || '', + f.updatedAt ? timeAgo(f.updatedAt) : '', + ]); + + printTable(rows, ['ID', 'NAME', 'TYPE', 'UPDATED']); + }); +} diff --git a/apps/cli/src/commands/kb.test.ts b/apps/cli/src/commands/kb.test.ts new file mode 100644 index 0000000000..5a70f04560 --- /dev/null +++ b/apps/cli/src/commands/kb.test.ts @@ -0,0 +1,206 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerKbCommand } from './kb'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + knowledgeBase: { + addFilesToKnowledgeBase: { mutate: vi.fn() }, + createKnowledgeBase: { mutate: vi.fn() }, + getKnowledgeBaseById: { query: vi.fn() }, + getKnowledgeBases: { query: vi.fn() }, + removeFilesFromKnowledgeBase: { mutate: vi.fn() }, + removeKnowledgeBase: { mutate: vi.fn() }, + updateKnowledgeBase: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('kb command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + // Reset all mocks + for (const router of Object.values(mockTrpcClient)) { + for (const method of Object.values(router)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerKbCommand(program); + return program; + } + + describe('list', () => { + it('should display knowledge bases in table format', async () => { + mockTrpcClient.knowledgeBase.getKnowledgeBases.query.mockResolvedValue([ + { description: 'My KB', id: 'kb1', name: 'Test KB', updatedAt: new Date().toISOString() }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row + expect(consoleSpy.mock.calls[0][0]).toContain('ID'); + }); + + it('should output JSON when --json flag is used', async () => { + const items = [{ id: 'kb1', name: 'Test' }]; + mockTrpcClient.knowledgeBase.getKnowledgeBases.query.mockResolvedValue(items); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'list', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2)); + }); + + it('should show message when no knowledge bases found', async () => { + mockTrpcClient.knowledgeBase.getKnowledgeBases.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'list']); + + expect(consoleSpy).toHaveBeenCalledWith('No knowledge bases found.'); + }); + }); + + describe('view', () => { + it('should display knowledge base details', async () => { + mockTrpcClient.knowledgeBase.getKnowledgeBaseById.query.mockResolvedValue({ + description: 'A test KB', + id: 'kb1', + name: 'Test KB', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'view', 'kb1']); + + expect(mockTrpcClient.knowledgeBase.getKnowledgeBaseById.query).toHaveBeenCalledWith({ + id: 'kb1', + }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test KB')); + }); + + it('should exit when not found', async () => { + mockTrpcClient.knowledgeBase.getKnowledgeBaseById.query.mockResolvedValue(null); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'view', 'nonexistent']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('create', () => { + it('should create a knowledge base', async () => { + mockTrpcClient.knowledgeBase.createKnowledgeBase.mutate.mockResolvedValue({ id: 'kb-new' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'kb', + 'create', + '--name', + 'New KB', + '--description', + 'Test desc', + ]); + + expect(mockTrpcClient.knowledgeBase.createKnowledgeBase.mutate).toHaveBeenCalledWith( + expect.objectContaining({ description: 'Test desc', name: 'New KB' }), + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('kb-new')); + }); + }); + + describe('edit', () => { + it('should update knowledge base', async () => { + mockTrpcClient.knowledgeBase.updateKnowledgeBase.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'edit', 'kb1', '--name', 'Updated']); + + expect(mockTrpcClient.knowledgeBase.updateKnowledgeBase.mutate).toHaveBeenCalledWith({ + id: 'kb1', + value: { name: 'Updated' }, + }); + }); + + it('should exit when no changes specified', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'edit', 'kb1']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('delete', () => { + it('should delete with --yes', async () => { + mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'delete', 'kb1', '--yes']); + + expect(mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate).toHaveBeenCalledWith({ + id: 'kb1', + removeFiles: undefined, + }); + }); + + it('should pass --remove-files flag', async () => { + mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'delete', 'kb1', '--yes', '--remove-files']); + + expect(mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate).toHaveBeenCalledWith({ + id: 'kb1', + removeFiles: true, + }); + }); + }); + + describe('add-files', () => { + it('should add files to knowledge base', async () => { + mockTrpcClient.knowledgeBase.addFilesToKnowledgeBase.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'kb', 'add-files', 'kb1', '--ids', 'f1', 'f2']); + + expect(mockTrpcClient.knowledgeBase.addFilesToKnowledgeBase.mutate).toHaveBeenCalledWith({ + ids: ['f1', 'f2'], + knowledgeBaseId: 'kb1', + }); + }); + }); +}); diff --git a/apps/cli/src/commands/kb.ts b/apps/cli/src/commands/kb.ts new file mode 100644 index 0000000000..1297dd763d --- /dev/null +++ b/apps/cli/src/commands/kb.ts @@ -0,0 +1,196 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerKbCommand(program: Command) { + const kb = program.command('kb').description('Manage knowledge bases'); + + // ── list ────────────────────────────────────────────── + + kb.command('list') + .description('List knowledge bases') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.knowledgeBase.getKnowledgeBases.query(); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No knowledge bases found.'); + return; + } + + const rows = items.map((kb: any) => [ + kb.id, + truncate(kb.name || 'Untitled', 40), + truncate(kb.description || '', 50), + kb.updatedAt ? timeAgo(kb.updatedAt) : '', + ]); + + printTable(rows, ['ID', 'NAME', 'DESCRIPTION', 'UPDATED']); + }); + + // ── view ────────────────────────────────────────────── + + kb.command('view <id>') + .description('View a knowledge base') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (id: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.knowledgeBase.getKnowledgeBaseById.query({ id }); + + if (!result) { + log.error(`Knowledge base not found: ${id}`); + process.exit(1); + return; + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + console.log(pc.bold(result.name || 'Untitled')); + const meta: string[] = []; + if (result.description) meta.push(result.description); + if ((result as any).updatedAt) meta.push(`Updated ${timeAgo((result as any).updatedAt)}`); + if (meta.length > 0) console.log(pc.dim(meta.join(' · '))); + + // Show files if available + if ((result as any).files && Array.isArray((result as any).files)) { + const files = (result as any).files; + if (files.length > 0) { + console.log(); + console.log(pc.bold(`Files (${files.length}):`)); + const rows = files.map((f: any) => [ + f.id, + truncate(f.name || f.filename || '', 50), + f.fileType || '', + ]); + printTable(rows, ['ID', 'NAME', 'TYPE']); + } + } + }); + + // ── create ──────────────────────────────────────────── + + kb.command('create') + .description('Create a knowledge base') + .requiredOption('-n, --name <name>', 'Knowledge base name') + .option('-d, --description <desc>', 'Description') + .option('--avatar <url>', 'Avatar URL') + .action(async (options: { avatar?: string; description?: string; name: string }) => { + const client = await getTrpcClient(); + + const input: { avatar?: string; description?: string; name: string } = { + name: options.name, + }; + if (options.description) input.description = options.description; + if (options.avatar) input.avatar = options.avatar; + + const result = await client.knowledgeBase.createKnowledgeBase.mutate(input); + console.log(`${pc.green('✓')} Created knowledge base ${pc.bold((result as any).id)}`); + }); + + // ── edit ────────────────────────────────────────────── + + kb.command('edit <id>') + .description('Update a knowledge base') + .option('-n, --name <name>', 'New name') + .option('-d, --description <desc>', 'New description') + .option('--avatar <url>', 'New avatar URL') + .action( + async (id: string, options: { avatar?: string; description?: string; name?: string }) => { + if (!options.name && !options.description && !options.avatar) { + log.error('No changes specified. Use --name, --description, or --avatar.'); + process.exit(1); + } + + const client = await getTrpcClient(); + + const value: Record<string, any> = {}; + if (options.name) value.name = options.name; + if (options.description) value.description = options.description; + if (options.avatar) value.avatar = options.avatar; + + await client.knowledgeBase.updateKnowledgeBase.mutate({ id, value }); + console.log(`${pc.green('✓')} Updated knowledge base ${pc.bold(id)}`); + }, + ); + + // ── delete ──────────────────────────────────────────── + + kb.command('delete <id>') + .description('Delete a knowledge base') + .option('--remove-files', 'Also delete associated files') + .option('--yes', 'Skip confirmation prompt') + .action(async (id: string, options: { removeFiles?: boolean; yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm('Are you sure you want to delete this knowledge base?'); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + await client.knowledgeBase.removeKnowledgeBase.mutate({ + id, + removeFiles: options.removeFiles, + }); + console.log(`${pc.green('✓')} Deleted knowledge base ${pc.bold(id)}`); + }); + + // ── add-files ───────────────────────────────────────── + + kb.command('add-files <knowledgeBaseId>') + .description('Add files to a knowledge base') + .requiredOption('--ids <ids...>', 'File IDs to add') + .action(async (knowledgeBaseId: string, options: { ids: string[] }) => { + const client = await getTrpcClient(); + await client.knowledgeBase.addFilesToKnowledgeBase.mutate({ + ids: options.ids, + knowledgeBaseId, + }); + console.log( + `${pc.green('✓')} Added ${options.ids.length} file(s) to knowledge base ${pc.bold(knowledgeBaseId)}`, + ); + }); + + // ── remove-files ────────────────────────────────────── + + kb.command('remove-files <knowledgeBaseId>') + .description('Remove files from a knowledge base') + .requiredOption('--ids <ids...>', 'File IDs to remove') + .option('--yes', 'Skip confirmation prompt') + .action(async (knowledgeBaseId: string, options: { ids: string[]; yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm( + `Remove ${options.ids.length} file(s) from knowledge base?`, + ); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + await client.knowledgeBase.removeFilesFromKnowledgeBase.mutate({ + ids: options.ids, + knowledgeBaseId, + }); + console.log( + `${pc.green('✓')} Removed ${options.ids.length} file(s) from knowledge base ${pc.bold(knowledgeBaseId)}`, + ); + }); +} diff --git a/apps/cli/src/commands/login.test.ts b/apps/cli/src/commands/login.test.ts index a4304aba76..83acddf1f1 100644 --- a/apps/cli/src/commands/login.test.ts +++ b/apps/cli/src/commands/login.test.ts @@ -132,12 +132,6 @@ describe('login command', () => { }); it('should handle device auth failure', async () => { - // For early-exit tests, process.exit must throw to stop code execution - // (otherwise code continues past exit and accesses undefined deviceAuth) - exitSpy.mockImplementation(() => { - throw new Error('exit'); - }); - vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 500, @@ -145,21 +139,17 @@ describe('login command', () => { } as any); const program = createProgram(); - await runLoginAndAdvanceTimers(program).catch(() => {}); + await runLoginAndAdvanceTimers(program); expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start')); expect(exitSpy).toHaveBeenCalledWith(1); }); it('should handle network error on device auth', async () => { - exitSpy.mockImplementation(() => { - throw new Error('exit'); - }); - vi.mocked(fetch).mockRejectedValueOnce(new Error('ECONNREFUSED')); const program = createProgram(); - await runLoginAndAdvanceTimers(program).catch(() => {}); + await runLoginAndAdvanceTimers(program); expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to reach')); expect(exitSpy).toHaveBeenCalledWith(1); diff --git a/apps/cli/src/commands/login.ts b/apps/cli/src/commands/login.ts index 3387004576..94e594ffce 100644 --- a/apps/cli/src/commands/login.ts +++ b/apps/cli/src/commands/login.ts @@ -73,6 +73,7 @@ export function registerLoginCommand(program: Command) { const text = await res.text(); log.error(`Failed to start device authorization: ${res.status} ${text}`); process.exit(1); + return; } deviceAuth = await parseJsonResponse<DeviceAuthResponse>(res, '/oidc/device/auth'); @@ -80,6 +81,7 @@ export function registerLoginCommand(program: Command) { log.error(`Failed to reach server: ${error.message}`); log.error(`Make sure ${serverUrl} is reachable.`); process.exit(1); + return; } // Step 2: Show user code and open browser @@ -139,16 +141,17 @@ export function registerLoginCommand(program: Command) { case 'access_denied': { log.error('Authorization denied by user.'); process.exit(1); - break; + return; } case 'expired_token': { log.error('Device code expired. Please run login again.'); process.exit(1); - break; + return; } default: { log.error(`Authorization error: ${body.error} - ${body.error_description || ''}`); process.exit(1); + return; } } } else if (body.access_token) { diff --git a/apps/cli/src/commands/memory.test.ts b/apps/cli/src/commands/memory.test.ts new file mode 100644 index 0000000000..93017d36cf --- /dev/null +++ b/apps/cli/src/commands/memory.test.ts @@ -0,0 +1,215 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerMemoryCommand } from './memory'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + userMemory: { + createIdentity: { mutate: vi.fn() }, + deleteIdentity: { mutate: vi.fn() }, + getActivities: { query: vi.fn() }, + getContexts: { query: vi.fn() }, + getExperiences: { query: vi.fn() }, + getIdentities: { query: vi.fn() }, + getMemoryExtractionTask: { query: vi.fn() }, + getPersona: { query: vi.fn() }, + getPreferences: { query: vi.fn() }, + requestMemoryFromChatTopic: { mutate: vi.fn() }, + updateIdentity: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('memory command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.userMemory)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerMemoryCommand(program); + return program; + } + + describe('list', () => { + it('should list all categories when no category specified', async () => { + mockTrpcClient.userMemory.getIdentities.query.mockResolvedValue([ + { description: 'Dev', id: '1', type: 'professional' }, + ]); + mockTrpcClient.userMemory.getActivities.query.mockResolvedValue([]); + mockTrpcClient.userMemory.getContexts.query.mockResolvedValue([]); + mockTrpcClient.userMemory.getExperiences.query.mockResolvedValue([]); + mockTrpcClient.userMemory.getPreferences.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'list']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Identity')); + }); + + it('should list specific category', async () => { + mockTrpcClient.userMemory.getIdentities.query.mockResolvedValue([ + { description: 'Dev', id: '1', type: 'professional' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'list', 'identity']); + + expect(mockTrpcClient.userMemory.getIdentities.query).toHaveBeenCalled(); + }); + + it('should output JSON', async () => { + const items = [{ id: '1', type: 'professional' }]; + mockTrpcClient.userMemory.getIdentities.query.mockResolvedValue(items); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'list', 'identity', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2)); + }); + + it('should reject invalid category', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'list', 'invalid']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid category')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('create', () => { + it('should create an identity memory', async () => { + mockTrpcClient.userMemory.createIdentity.mutate.mockResolvedValue({ id: 'mem-1' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'memory', + 'create', + '--type', + 'professional', + '--description', + 'Software dev', + ]); + + expect(mockTrpcClient.userMemory.createIdentity.mutate).toHaveBeenCalledWith( + expect.objectContaining({ description: 'Software dev', type: 'professional' }), + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('mem-1')); + }); + }); + + describe('edit', () => { + it('should update an identity memory', async () => { + mockTrpcClient.userMemory.updateIdentity.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'memory', + 'edit', + 'identity', + 'mem-1', + '--description', + 'Updated desc', + ]); + + expect(mockTrpcClient.userMemory.updateIdentity.mutate).toHaveBeenCalledWith({ + data: { description: 'Updated desc' }, + id: 'mem-1', + }); + }); + }); + + describe('delete', () => { + it('should delete a memory with --yes', async () => { + mockTrpcClient.userMemory.deleteIdentity.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'delete', 'identity', 'mem-1', '--yes']); + + expect(mockTrpcClient.userMemory.deleteIdentity.mutate).toHaveBeenCalledWith({ + id: 'mem-1', + }); + }); + }); + + describe('persona', () => { + it('should display persona', async () => { + mockTrpcClient.userMemory.getPersona.query.mockResolvedValue('You are a developer.'); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'persona']); + + expect(consoleSpy).toHaveBeenCalledWith('You are a developer.'); + }); + + it('should output JSON', async () => { + const persona = { summary: 'Developer' }; + mockTrpcClient.userMemory.getPersona.query.mockResolvedValue(persona); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'persona', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(persona, null, 2)); + }); + }); + + describe('extract', () => { + it('should start memory extraction', async () => { + mockTrpcClient.userMemory.requestMemoryFromChatTopic.mutate.mockResolvedValue({ + id: 'task-1', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'extract']); + + expect(mockTrpcClient.userMemory.requestMemoryFromChatTopic.mutate).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('extraction started')); + }); + }); + + describe('extract-status', () => { + it('should show extraction task status', async () => { + mockTrpcClient.userMemory.getMemoryExtractionTask.query.mockResolvedValue({ + id: 'task-1', + status: 'completed', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'memory', 'extract-status']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('task-1')); + }); + }); +}); diff --git a/apps/cli/src/commands/memory.ts b/apps/cli/src/commands/memory.ts new file mode 100644 index 0000000000..4be0947892 --- /dev/null +++ b/apps/cli/src/commands/memory.ts @@ -0,0 +1,345 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +// ── Memory Categories ─────────────────────────────────────── + +const CATEGORIES = ['identity', 'activity', 'context', 'experience', 'preference'] as const; +type Category = (typeof CATEGORIES)[number]; + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +export function registerMemoryCommand(program: Command) { + const memory = program.command('memory').description('Manage user memories'); + + // ── list ────────────────────────────────────────────── + + memory + .command('list') + .description('List memories by category') + .argument('[category]', `Memory category: ${CATEGORIES.join(', ')} (default: all)`) + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (category: string | undefined, options: { json?: string | boolean }) => { + if (category && !CATEGORIES.includes(category as Category)) { + log.error(`Invalid category: ${category}. Must be one of: ${CATEGORIES.join(', ')}`); + process.exit(1); + } + + const client = await getTrpcClient(); + const categoriesToFetch = category ? [category as Category] : [...CATEGORIES]; + const allResults: Record<string, any[]> = {}; + + for (const cat of categoriesToFetch) { + const getter = `get${capitalize(cat)}` as string; + const getterPlural = `${getter}s` as string; + + // Try plural first (getIdentities, getActivities, etc.), then singular + const router = client.userMemory as any; + try { + if (router[getterPlural]) { + allResults[cat] = await router[getterPlural].query(); + } else if (router[getter]) { + allResults[cat] = await router[getter].query(); + } else { + // Try the special name patterns + const items = await fetchCategory(client, cat); + allResults[cat] = items; + } + } catch { + allResults[cat] = []; + } + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(category ? allResults[category] : allResults, fields); + return; + } + + for (const [cat, items] of Object.entries(allResults)) { + if (!Array.isArray(items) || items.length === 0) { + if (category) console.log(`No ${cat} memories found.`); + continue; + } + + console.log(); + console.log(pc.bold(pc.cyan(`── ${capitalize(cat)} (${items.length}) ──`))); + + const rows = items.map((item: any) => { + const desc = + item.description || + item.narrative || + item.title || + item.situation || + item.conclusionDirectives || + item.content || + ''; + return [ + item.id || '', + truncate(item.type || item.role || item.status || '', 20), + truncate(desc, 60), + ]; + }); + + printTable(rows, ['ID', 'TYPE/STATUS', 'DESCRIPTION']); + } + }); + + // ── create ──────────────────────────────────────────── + + memory + .command('create') + .description('Create an identity memory entry (other categories are created via extraction)') + .option('--type <type>', 'Memory type') + .option('--role <role>', 'Role') + .option('--relationship <rel>', 'Relationship') + .option('-d, --description <desc>', 'Description') + .option('--labels <labels...>', 'Extracted labels') + .action(async (options: Record<string, any>) => { + const client = await getTrpcClient(); + + const input: Record<string, any> = {}; + if (options.type) input.type = options.type; + if (options.role) input.role = options.role; + if (options.relationship) input.relationship = options.relationship; + if (options.description) input.description = options.description; + if (options.labels) input.extractedLabels = options.labels; + + try { + const result = await (client.userMemory as any).createIdentity.mutate(input); + const id = result?.id || 'unknown'; + console.log(`${pc.green('✓')} Created identity memory ${pc.bold(id)}`); + } catch (error: any) { + log.error(`Failed to create identity: ${error.message}`); + process.exit(1); + return; + } + }); + + // ── edit ────────────────────────────────────────────── + + memory + .command('edit <category> <id>') + .description(`Update a memory entry (${CATEGORIES.join(', ')})`) + .option('--type <type>', 'Memory type (for identity)') + .option('--role <role>', 'Role (for identity)') + .option('--relationship <rel>', 'Relationship (for identity)') + .option('-d, --description <desc>', 'Description') + .option('--narrative <text>', 'Narrative (for activity)') + .option('--notes <text>', 'Notes (for activity)') + .option('--status <status>', 'Status (for activity/context)') + .option('--title <title>', 'Title (for context)') + .option('--situation <text>', 'Situation (for experience)') + .option('--action <text>', 'Action (for experience)') + .option('--key-learning <text>', 'Key learning (for experience)') + .option('--directives <text>', 'Conclusion directives (for preference)') + .option('--suggestions <text>', 'Suggestions (for preference)') + .option('--labels <labels...>', 'Extracted labels') + .action(async (category: string, id: string, options: Record<string, any>) => { + if (!CATEGORIES.includes(category as Category)) { + log.error(`Invalid category: ${category}. Must be one of: ${CATEGORIES.join(', ')}`); + process.exit(1); + } + + const client = await getTrpcClient(); + const router = client.userMemory as any; + const mutationName = `update${capitalize(category)}`; + + const data = buildCategoryInput(category as Category, options); + + try { + await router[mutationName].mutate({ data, id }); + console.log(`${pc.green('✓')} Updated ${category} memory ${pc.bold(id)}`); + } catch (error: any) { + log.error(`Failed to update ${category}: ${error.message}`); + process.exit(1); + return; + } + }); + + // ── delete ──────────────────────────────────────────── + + memory + .command('delete <category> <id>') + .description(`Delete a memory entry (${CATEGORIES.join(', ')})`) + .option('--yes', 'Skip confirmation prompt') + .action(async (category: string, id: string, options: { yes?: boolean }) => { + if (!CATEGORIES.includes(category as Category)) { + log.error(`Invalid category: ${category}. Must be one of: ${CATEGORIES.join(', ')}`); + process.exit(1); + } + + if (!options.yes) { + const confirmed = await confirm(`Delete this ${category} memory?`); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + const router = client.userMemory as any; + const mutationName = `delete${capitalize(category)}`; + + try { + await router[mutationName].mutate({ id }); + console.log(`${pc.green('✓')} Deleted ${category} memory ${pc.bold(id)}`); + } catch (error: any) { + log.error(`Failed to delete ${category}: ${error.message}`); + process.exit(1); + return; + } + }); + + // ── persona ─────────────────────────────────────────── + + memory + .command('persona') + .description('View your memory persona summary') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const persona = await client.userMemory.getPersona.query(); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(persona, fields); + return; + } + + if (!persona) { + console.log('No persona data available.'); + return; + } + + console.log(pc.bold('User Persona')); + console.log(); + console.log(typeof persona === 'string' ? persona : JSON.stringify(persona, null, 2)); + }); + + // ── extract ─────────────────────────────────────────── + + memory + .command('extract') + .description('Extract memories from chat history') + .option('--from <date>', 'Start date (ISO format)') + .option('--to <date>', 'End date (ISO format)') + .action(async (options: { from?: string; to?: string }) => { + const client = await getTrpcClient(); + + const input: { fromDate?: Date; toDate?: Date } = {}; + if (options.from) input.fromDate = new Date(options.from); + if (options.to) input.toDate = new Date(options.to); + + const result = await client.userMemory.requestMemoryFromChatTopic.mutate(input); + console.log(`${pc.green('✓')} Memory extraction started`); + if ((result as any)?.id) { + console.log(`Task ID: ${pc.bold((result as any).id)}`); + } + console.log(pc.dim('Use "lh memory extract-status" to check progress.')); + }); + + // ── extract-status ──────────────────────────────────── + + memory + .command('extract-status') + .description('Check memory extraction task status') + .option('--task-id <id>', 'Specific task ID to check') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean; taskId?: string }) => { + const client = await getTrpcClient(); + + const input: { taskId?: string } = {}; + if (options.taskId) input.taskId = options.taskId; + + const result = await client.userMemory.getMemoryExtractionTask.query(input); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + if (!result) { + console.log('No extraction task found.'); + return; + } + + const r = result as any; + console.log(pc.bold('Memory Extraction Task')); + if (r.id) console.log(` ID: ${r.id}`); + if (r.status) console.log(` Status: ${r.status}`); + if (r.metadata) console.log(` Detail: ${JSON.stringify(r.metadata)}`); + }); +} + +// ── Helpers ───────────────────────────────────────────────── + +async function fetchCategory(client: any, category: Category): Promise<any[]> { + const router = client.userMemory; + switch (category) { + case 'identity': { + return router.getIdentities.query(); + } + case 'activity': { + return router.getActivities.query(); + } + case 'context': { + return router.getContexts.query(); + } + case 'experience': { + return router.getExperiences.query(); + } + case 'preference': { + return router.getPreferences.query(); + } + default: { + return []; + } + } +} + +function buildCategoryInput(category: Category, options: Record<string, any>): Record<string, any> { + const input: Record<string, any> = {}; + + switch (category) { + case 'identity': { + if (options.type) input.type = options.type; + if (options.role) input.role = options.role; + if (options.relationship) input.relationship = options.relationship; + if (options.description) input.description = options.description; + if (options.labels) input.extractedLabels = options.labels; + break; + } + case 'activity': { + if (options.narrative) input.narrative = options.narrative; + if (options.notes) input.notes = options.notes; + if (options.status) input.status = options.status; + break; + } + case 'context': { + if (options.title) input.title = options.title; + if (options.description) input.description = options.description; + if (options.status) input.currentStatus = options.status; + break; + } + case 'experience': { + if (options.situation) input.situation = options.situation; + if (options.action) input.action = options.action; + if (options.keyLearning) input.keyLearning = options.keyLearning; + break; + } + case 'preference': { + if (options.directives) input.conclusionDirectives = options.directives; + if (options.suggestions) input.suggestions = options.suggestions; + break; + } + } + + return input; +} diff --git a/apps/cli/src/commands/message.test.ts b/apps/cli/src/commands/message.test.ts new file mode 100644 index 0000000000..a31ed80a25 --- /dev/null +++ b/apps/cli/src/commands/message.test.ts @@ -0,0 +1,134 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerMessageCommand } from './message'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + message: { + count: { query: vi.fn() }, + getHeatmaps: { query: vi.fn() }, + getMessages: { query: vi.fn() }, + removeMessage: { mutate: vi.fn() }, + removeMessages: { mutate: vi.fn() }, + searchMessages: { query: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('message command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.message)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerMessageCommand(program); + return program; + } + + describe('list', () => { + it('should display messages', async () => { + mockTrpcClient.message.getMessages.query.mockResolvedValue([ + { content: 'Hello', createdAt: new Date().toISOString(), id: 'm1', role: 'user' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'message', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + it('should filter by topic-id', async () => { + mockTrpcClient.message.getMessages.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'message', 'list', '--topic-id', 't1']); + + expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith( + expect.objectContaining({ topicId: 't1' }), + ); + }); + }); + + describe('search', () => { + it('should search messages', async () => { + mockTrpcClient.message.searchMessages.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'message', 'search', 'hello']); + + expect(mockTrpcClient.message.searchMessages.query).toHaveBeenCalledWith({ + keywords: 'hello', + }); + }); + }); + + describe('delete', () => { + it('should delete single message', async () => { + mockTrpcClient.message.removeMessage.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'message', 'delete', 'm1', '--yes']); + + expect(mockTrpcClient.message.removeMessage.mutate).toHaveBeenCalledWith({ id: 'm1' }); + }); + + it('should batch delete messages', async () => { + mockTrpcClient.message.removeMessages.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'message', 'delete', 'm1', 'm2', '--yes']); + + expect(mockTrpcClient.message.removeMessages.mutate).toHaveBeenCalledWith({ + ids: ['m1', 'm2'], + }); + }); + }); + + describe('count', () => { + it('should count messages', async () => { + mockTrpcClient.message.count.query.mockResolvedValue(42); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'message', 'count']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('42')); + }); + + it('should output JSON', async () => { + mockTrpcClient.message.count.query.mockResolvedValue(42); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'message', 'count', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({ count: 42 })); + }); + }); +}); diff --git a/apps/cli/src/commands/message.ts b/apps/cli/src/commands/message.ts new file mode 100644 index 0000000000..04edafe8fb --- /dev/null +++ b/apps/cli/src/commands/message.ts @@ -0,0 +1,171 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format'; + +export function registerMessageCommand(program: Command) { + const message = program.command('message').description('Manage messages'); + + // ── list ────────────────────────────────────────────── + + message + .command('list') + .description('List messages') + .option('--topic-id <id>', 'Filter by topic ID') + .option('--agent-id <id>', 'Filter by agent ID') + .option('--session-id <id>', 'Filter by session ID') + .option('-L, --limit <n>', 'Page size', '30') + .option('--page <n>', 'Page number', '1') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action( + async (options: { + agentId?: string; + json?: string | boolean; + limit?: string; + page?: string; + sessionId?: string; + topicId?: string; + }) => { + const client = await getTrpcClient(); + + const input: Record<string, any> = {}; + if (options.topicId) input.topicId = options.topicId; + if (options.agentId) input.agentId = options.agentId; + if (options.sessionId) input.sessionId = options.sessionId; + if (options.limit) input.pageSize = Number.parseInt(options.limit, 10); + if (options.page) input.current = Number.parseInt(options.page, 10); + + const result = await client.message.getMessages.query(input as any); + const items = Array.isArray(result) ? result : ((result as any).items ?? []); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No messages found.'); + return; + } + + const rows = items.map((m: any) => [ + m.id || '', + m.role || '', + truncate(m.content || '', 60), + m.createdAt ? timeAgo(m.createdAt) : '', + ]); + + printTable(rows, ['ID', 'ROLE', 'CONTENT', 'CREATED']); + }, + ); + + // ── search ──────────────────────────────────────────── + + message + .command('search <keywords>') + .description('Search messages') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (keywords: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.message.searchMessages.query({ keywords }); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No messages found.'); + return; + } + + const rows = items.map((m: any) => [m.id || '', m.role || '', truncate(m.content || '', 60)]); + + printTable(rows, ['ID', 'ROLE', 'CONTENT']); + }); + + // ── delete ──────────────────────────────────────────── + + message + .command('delete <ids...>') + .description('Delete one or more messages') + .option('--yes', 'Skip confirmation prompt') + .action(async (ids: string[], options: { yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm( + `Are you sure you want to delete ${ids.length} message(s)?`, + ); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + + if (ids.length === 1) { + await client.message.removeMessage.mutate({ id: ids[0] }); + } else { + await client.message.removeMessages.mutate({ ids }); + } + + console.log(`${pc.green('✓')} Deleted ${ids.length} message(s)`); + }); + + // ── count ───────────────────────────────────────────── + + message + .command('count') + .description('Count messages') + .option('--start <date>', 'Start date (ISO format)') + .option('--end <date>', 'End date (ISO format)') + .option('--json', 'Output JSON') + .action(async (options: { end?: string; json?: boolean; start?: string }) => { + const client = await getTrpcClient(); + + const input: Record<string, any> = {}; + if (options.start) input.startDate = options.start; + if (options.end) input.endDate = options.end; + + const count = await client.message.count.query(input as any); + + if (options.json) { + console.log(JSON.stringify({ count })); + return; + } + + console.log(`Messages: ${pc.bold(String(count))}`); + }); + + // ── heatmap ─────────────────────────────────────────── + + message + .command('heatmap') + .description('Get message activity heatmap') + .option('--json', 'Output JSON') + .action(async (options: { json?: boolean }) => { + const client = await getTrpcClient(); + const result = await client.message.getHeatmaps.query(); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (!result || (Array.isArray(result) && result.length === 0)) { + console.log('No heatmap data.'); + return; + } + + // Display as simple list + const items = Array.isArray(result) ? result : [result]; + for (const entry of items) { + const e = entry as any; + console.log(`${e.date || e.day || ''}: ${pc.bold(String(e.count || e.value || 0))}`); + } + }); +} diff --git a/apps/cli/src/commands/model.test.ts b/apps/cli/src/commands/model.test.ts new file mode 100644 index 0000000000..63bc520d11 --- /dev/null +++ b/apps/cli/src/commands/model.test.ts @@ -0,0 +1,140 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerModelCommand } from './model'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + aiModel: { + getAiModelById: { query: vi.fn() }, + getAiProviderModelList: { query: vi.fn() }, + removeAiModel: { mutate: vi.fn() }, + toggleModelEnabled: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('model command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.aiModel)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerModelCommand(program); + return program; + } + + describe('list', () => { + it('should list models for provider', async () => { + mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([ + { displayName: 'GPT-4', enabled: true, id: 'gpt-4', type: 'chat' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'model', 'list', 'openai']); + + expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith( + expect.objectContaining({ id: 'openai' }), + ); + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('view', () => { + it('should display model details', async () => { + mockTrpcClient.aiModel.getAiModelById.query.mockResolvedValue({ + displayName: 'GPT-4', + enabled: true, + id: 'gpt-4', + providerId: 'openai', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'model', 'view', 'gpt-4']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('GPT-4')); + }); + + it('should exit when not found', async () => { + mockTrpcClient.aiModel.getAiModelById.query.mockResolvedValue(null); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'model', 'view', 'nonexistent']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + }); + }); + + describe('toggle', () => { + it('should enable model', async () => { + mockTrpcClient.aiModel.toggleModelEnabled.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'model', + 'toggle', + 'gpt-4', + '--provider', + 'openai', + '--enable', + ]); + + expect(mockTrpcClient.aiModel.toggleModelEnabled.mutate).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, id: 'gpt-4' }), + ); + }); + }); + + describe('delete', () => { + it('should delete model', async () => { + mockTrpcClient.aiModel.removeAiModel.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'model', + 'delete', + 'gpt-4', + '--provider', + 'openai', + '--yes', + ]); + + expect(mockTrpcClient.aiModel.removeAiModel.mutate).toHaveBeenCalledWith({ + id: 'gpt-4', + providerId: 'openai', + }); + }); + }); +}); diff --git a/apps/cli/src/commands/model.ts b/apps/cli/src/commands/model.ts new file mode 100644 index 0000000000..0280888268 --- /dev/null +++ b/apps/cli/src/commands/model.ts @@ -0,0 +1,133 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerModelCommand(program: Command) { + const model = program.command('model').description('Manage AI models'); + + // ── list ────────────────────────────────────────────── + + model + .command('list <providerId>') + .description('List models for a provider') + .option('-L, --limit <n>', 'Maximum number of items', '50') + .option('--enabled', 'Only show enabled models') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action( + async ( + providerId: string, + options: { enabled?: boolean; json?: string | boolean; limit?: string }, + ) => { + const client = await getTrpcClient(); + + const input: Record<string, any> = { id: providerId }; + if (options.limit) input.limit = Number.parseInt(options.limit, 10); + if (options.enabled) input.enabled = true; + + const result = await client.aiModel.getAiProviderModelList.query(input as any); + const items = Array.isArray(result) ? result : ((result as any).items ?? []); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No models found.'); + return; + } + + const rows = items.map((m: any) => [ + m.id || '', + truncate(m.displayName || m.id || '', 40), + m.enabled ? pc.green('✓') : pc.dim('✗'), + m.type || '', + ]); + + printTable(rows, ['ID', 'NAME', 'ENABLED', 'TYPE']); + }, + ); + + // ── view ────────────────────────────────────────────── + + model + .command('view <id>') + .description('View model details') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (id: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.aiModel.getAiModelById.query({ id }); + + if (!result) { + log.error(`Model not found: ${id}`); + process.exit(1); + return; + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + const r = result as any; + console.log(pc.bold(r.displayName || r.id || 'Unknown')); + const meta: string[] = []; + if (r.providerId) meta.push(`Provider: ${r.providerId}`); + if (r.type) meta.push(`Type: ${r.type}`); + if (r.enabled !== undefined) meta.push(r.enabled ? 'Enabled' : 'Disabled'); + if (meta.length > 0) console.log(pc.dim(meta.join(' · '))); + }); + + // ── toggle ──────────────────────────────────────────── + + model + .command('toggle <id>') + .description('Enable or disable a model') + .requiredOption('--provider <providerId>', 'Provider ID') + .option('--enable', 'Enable the model') + .option('--disable', 'Disable the model') + .action( + async (id: string, options: { disable?: boolean; enable?: boolean; provider: string }) => { + if (options.enable === undefined && options.disable === undefined) { + log.error('Specify --enable or --disable.'); + process.exit(1); + } + + const client = await getTrpcClient(); + const enabled = options.enable === true; + + await client.aiModel.toggleModelEnabled.mutate({ + enabled, + id, + providerId: options.provider, + } as any); + console.log(`${pc.green('✓')} Model ${pc.bold(id)} ${enabled ? 'enabled' : 'disabled'}`); + }, + ); + + // ── delete ──────────────────────────────────────────── + + model + .command('delete <id>') + .description('Delete a model') + .requiredOption('--provider <providerId>', 'Provider ID') + .option('--yes', 'Skip confirmation prompt') + .action(async (id: string, options: { provider: string; yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm('Are you sure you want to delete this model?'); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + await client.aiModel.removeAiModel.mutate({ id, providerId: options.provider }); + console.log(`${pc.green('✓')} Deleted model ${pc.bold(id)}`); + }); +} diff --git a/apps/cli/src/commands/plugin.test.ts b/apps/cli/src/commands/plugin.test.ts new file mode 100644 index 0000000000..f94e93c768 --- /dev/null +++ b/apps/cli/src/commands/plugin.test.ts @@ -0,0 +1,159 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerPluginCommand } from './plugin'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + plugin: { + createOrInstallPlugin: { mutate: vi.fn() }, + getPlugins: { query: vi.fn() }, + removePlugin: { mutate: vi.fn() }, + updatePlugin: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('plugin command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.plugin)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerPluginCommand(program); + return program; + } + + describe('list', () => { + it('should list plugins', async () => { + mockTrpcClient.plugin.getPlugins.query.mockResolvedValue([ + { id: 'p1', identifier: 'search', type: 'plugin' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'plugin', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + it('should output JSON', async () => { + const plugins = [{ id: 'p1', identifier: 'search' }]; + mockTrpcClient.plugin.getPlugins.query.mockResolvedValue(plugins); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'plugin', 'list', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(plugins, null, 2)); + }); + }); + + describe('install', () => { + it('should install a plugin', async () => { + mockTrpcClient.plugin.createOrInstallPlugin.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'plugin', + 'install', + '-i', + 'my-plugin', + '--manifest', + '{"name":"test"}', + ]); + + expect(mockTrpcClient.plugin.createOrInstallPlugin.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + identifier: 'my-plugin', + manifest: { name: 'test' }, + }), + ); + }); + + it('should reject invalid manifest JSON', async () => { + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'plugin', + 'install', + '-i', + 'my-plugin', + '--manifest', + 'not-json', + ]); + + expect(log.error).toHaveBeenCalledWith('Invalid manifest JSON.'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('uninstall', () => { + it('should uninstall with --yes', async () => { + mockTrpcClient.plugin.removePlugin.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'plugin', 'uninstall', 'p1', '--yes']); + + expect(mockTrpcClient.plugin.removePlugin.mutate).toHaveBeenCalledWith({ id: 'p1' }); + }); + }); + + describe('update', () => { + it('should update plugin settings', async () => { + mockTrpcClient.plugin.updatePlugin.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'plugin', + 'update', + 'p1', + '--settings', + '{"key":"value"}', + ]); + + expect(mockTrpcClient.plugin.updatePlugin.mutate).toHaveBeenCalledWith( + expect.objectContaining({ id: 'p1', settings: { key: 'value' } }), + ); + }); + + it('should exit when no changes', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', 'plugin', 'update', 'p1']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts new file mode 100644 index 0000000000..a33ec2ff06 --- /dev/null +++ b/apps/cli/src/commands/plugin.ts @@ -0,0 +1,146 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerPluginCommand(program: Command) { + const plugin = program.command('plugin').description('Manage plugins'); + + // ── list ────────────────────────────────────────────── + + plugin + .command('list') + .description('List installed plugins') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.plugin.getPlugins.query(); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No plugins installed.'); + return; + } + + const rows = items.map((p: any) => [ + p.id || '', + truncate(p.identifier || '', 30), + p.type || '', + truncate(p.manifest?.meta?.title || p.manifest?.identifier || '', 30), + ]); + + printTable(rows, ['ID', 'IDENTIFIER', 'TYPE', 'TITLE']); + }); + + // ── install ─────────────────────────────────────────── + + plugin + .command('install') + .description('Install a plugin') + .requiredOption('-i, --identifier <id>', 'Plugin identifier') + .requiredOption('--manifest <json>', 'Plugin manifest JSON') + .option('--type <type>', 'Plugin type: plugin or customPlugin', 'plugin') + .option('--settings <json>', 'Plugin settings JSON') + .action( + async (options: { + identifier: string; + manifest: string; + settings?: string; + type: string; + }) => { + const client = await getTrpcClient(); + + let manifest: any; + let settings: any; + try { + manifest = JSON.parse(options.manifest); + } catch { + log.error('Invalid manifest JSON.'); + process.exit(1); + } + if (options.settings) { + try { + settings = JSON.parse(options.settings); + } catch { + log.error('Invalid settings JSON.'); + process.exit(1); + } + } + + await client.plugin.createOrInstallPlugin.mutate({ + customParams: {}, + identifier: options.identifier, + manifest, + settings, + type: options.type as 'plugin' | 'customPlugin', + }); + + console.log(`${pc.green('✓')} Installed plugin ${pc.bold(options.identifier)}`); + }, + ); + + // ── uninstall ───────────────────────────────────────── + + plugin + .command('uninstall <id>') + .description('Uninstall a plugin') + .option('--yes', 'Skip confirmation prompt') + .action(async (id: string, options: { yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm('Are you sure you want to uninstall this plugin?'); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + await client.plugin.removePlugin.mutate({ id }); + console.log(`${pc.green('✓')} Uninstalled plugin ${pc.bold(id)}`); + }); + + // ── update ──────────────────────────────────────────── + + plugin + .command('update <id>') + .description('Update plugin settings or manifest') + .option('--manifest <json>', 'New manifest JSON') + .option('--settings <json>', 'New settings JSON') + .action(async (id: string, options: { manifest?: string; settings?: string }) => { + const input: Record<string, any> = { id }; + + if (options.manifest) { + try { + input.manifest = JSON.parse(options.manifest); + } catch { + log.error('Invalid manifest JSON.'); + process.exit(1); + } + } + if (options.settings) { + try { + input.settings = JSON.parse(options.settings); + } catch { + log.error('Invalid settings JSON.'); + process.exit(1); + } + } + + if (!options.manifest && !options.settings) { + log.error('No changes specified. Use --manifest or --settings.'); + process.exit(1); + } + + const client = await getTrpcClient(); + await client.plugin.updatePlugin.mutate(input as any); + console.log(`${pc.green('✓')} Updated plugin ${pc.bold(id)}`); + }); +} diff --git a/apps/cli/src/commands/provider.test.ts b/apps/cli/src/commands/provider.test.ts new file mode 100644 index 0000000000..32f7cb1c23 --- /dev/null +++ b/apps/cli/src/commands/provider.test.ts @@ -0,0 +1,128 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerProviderCommand } from './provider'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + aiProvider: { + getAiProviderById: { query: vi.fn() }, + getAiProviderList: { query: vi.fn() }, + removeAiProvider: { mutate: vi.fn() }, + toggleProviderEnabled: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('provider command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.aiProvider)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerProviderCommand(program); + return program; + } + + describe('list', () => { + it('should list providers', async () => { + mockTrpcClient.aiProvider.getAiProviderList.query.mockResolvedValue([ + { enabled: true, id: 'openai', name: 'OpenAI' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'provider', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + it('should output JSON', async () => { + const providers = [{ id: 'openai', name: 'OpenAI' }]; + mockTrpcClient.aiProvider.getAiProviderList.query.mockResolvedValue(providers); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'provider', 'list', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(providers, null, 2)); + }); + }); + + describe('view', () => { + it('should display provider details', async () => { + mockTrpcClient.aiProvider.getAiProviderById.query.mockResolvedValue({ + enabled: true, + id: 'openai', + name: 'OpenAI', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'provider', 'view', 'openai']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('OpenAI')); + }); + + it('should exit when not found', async () => { + mockTrpcClient.aiProvider.getAiProviderById.query.mockResolvedValue(null); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'provider', 'view', 'nonexistent']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + }); + }); + + describe('toggle', () => { + it('should enable provider', async () => { + mockTrpcClient.aiProvider.toggleProviderEnabled.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'provider', 'toggle', 'openai', '--enable']); + + expect(mockTrpcClient.aiProvider.toggleProviderEnabled.mutate).toHaveBeenCalledWith({ + enabled: true, + id: 'openai', + }); + }); + }); + + describe('delete', () => { + it('should delete provider', async () => { + mockTrpcClient.aiProvider.removeAiProvider.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'provider', 'delete', 'openai', '--yes']); + + expect(mockTrpcClient.aiProvider.removeAiProvider.mutate).toHaveBeenCalledWith({ + id: 'openai', + }); + }); + }); +}); diff --git a/apps/cli/src/commands/provider.ts b/apps/cli/src/commands/provider.ts new file mode 100644 index 0000000000..f184d95216 --- /dev/null +++ b/apps/cli/src/commands/provider.ts @@ -0,0 +1,112 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerProviderCommand(program: Command) { + const provider = program.command('provider').description('Manage AI providers'); + + // ── list ────────────────────────────────────────────── + + provider + .command('list') + .description('List AI providers') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.aiProvider.getAiProviderList.query(); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No providers found.'); + return; + } + + const rows = items.map((p: any) => [ + p.id || '', + truncate(p.name || p.id || '', 30), + p.enabled ? pc.green('✓') : pc.dim('✗'), + p.source || '', + ]); + + printTable(rows, ['ID', 'NAME', 'ENABLED', 'SOURCE']); + }); + + // ── view ────────────────────────────────────────────── + + provider + .command('view <id>') + .description('View provider details') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (id: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.aiProvider.getAiProviderById.query({ id }); + + if (!result) { + log.error(`Provider not found: ${id}`); + process.exit(1); + return; + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + const r = result as any; + console.log(pc.bold(r.name || r.id || 'Unknown')); + const meta: string[] = []; + if (r.enabled !== undefined) meta.push(r.enabled ? 'Enabled' : 'Disabled'); + if (r.source) meta.push(`Source: ${r.source}`); + if (meta.length > 0) console.log(pc.dim(meta.join(' · '))); + }); + + // ── toggle ──────────────────────────────────────────── + + provider + .command('toggle <id>') + .description('Enable or disable a provider') + .option('--enable', 'Enable the provider') + .option('--disable', 'Disable the provider') + .action(async (id: string, options: { disable?: boolean; enable?: boolean }) => { + if (options.enable === undefined && options.disable === undefined) { + log.error('Specify --enable or --disable.'); + process.exit(1); + } + + const client = await getTrpcClient(); + const enabled = options.enable === true; + + await client.aiProvider.toggleProviderEnabled.mutate({ enabled, id }); + console.log(`${pc.green('✓')} Provider ${pc.bold(id)} ${enabled ? 'enabled' : 'disabled'}`); + }); + + // ── delete ──────────────────────────────────────────── + + provider + .command('delete <id>') + .description('Delete a provider') + .option('--yes', 'Skip confirmation prompt') + .action(async (id: string, options: { yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm('Are you sure you want to delete this provider?'); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + await client.aiProvider.removeAiProvider.mutate({ id }); + console.log(`${pc.green('✓')} Deleted provider ${pc.bold(id)}`); + }); +} diff --git a/apps/cli/src/commands/search.test.ts b/apps/cli/src/commands/search.test.ts new file mode 100644 index 0000000000..ea4867d8ce --- /dev/null +++ b/apps/cli/src/commands/search.test.ts @@ -0,0 +1,133 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerSearchCommand } from './search'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + search: { + query: { query: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('search command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + mockTrpcClient.search.query.query.mockReset(); + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerSearchCommand(program); + return program; + } + + it('should search with query string', async () => { + mockTrpcClient.search.query.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'search', 'hello']); + + expect(mockTrpcClient.search.query.query).toHaveBeenCalledWith( + expect.objectContaining({ query: 'hello' }), + ); + }); + + it('should filter by type', async () => { + mockTrpcClient.search.query.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'search', 'test', '--type', 'agent']); + + expect(mockTrpcClient.search.query.query).toHaveBeenCalledWith( + expect.objectContaining({ query: 'test', type: 'agent' }), + ); + }); + + it('should respect --limit flag', async () => { + mockTrpcClient.search.query.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'search', 'test', '-L', '5']); + + expect(mockTrpcClient.search.query.query).toHaveBeenCalledWith( + expect.objectContaining({ limitPerType: 5 }), + ); + }); + + it('should output JSON when --json flag is used', async () => { + const results = [{ id: '1', title: 'Test', type: 'agent' }]; + mockTrpcClient.search.query.query.mockResolvedValue(results); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'search', 'test', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(results, null, 2)); + }); + + it('should show message when no results found', async () => { + mockTrpcClient.search.query.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'search', 'nothing']); + + expect(consoleSpy).toHaveBeenCalledWith('No results found.'); + }); + + it('should display grouped results for array response', async () => { + mockTrpcClient.search.query.query.mockResolvedValue([ + { id: '1', title: 'Agent 1', type: 'agent' }, + { id: '2', title: 'Topic 1', type: 'topic' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'search', 'test']); + + // Should display group headers + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('agent')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('topic')); + }); + + it('should display grouped results for object response', async () => { + mockTrpcClient.search.query.query.mockResolvedValue({ + agents: [{ id: '1', title: 'Agent 1' }], + topics: [{ id: '2', title: 'Topic 1' }], + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'search', 'test']); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('agents')); + }); + + it('should reject invalid type', async () => { + const program = createProgram(); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + await program.parseAsync(['node', 'test', 'search', 'test', '--type', 'invalid']); + + expect(exitSpy).toHaveBeenCalledWith(1); + stderrSpy.mockRestore(); + }); +}); diff --git a/apps/cli/src/commands/search.ts b/apps/cli/src/commands/search.ts new file mode 100644 index 0000000000..a59783ea2b --- /dev/null +++ b/apps/cli/src/commands/search.ts @@ -0,0 +1,102 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { outputJson, printTable, truncate } from '../utils/format'; + +const SEARCH_TYPES = [ + 'agent', + 'topic', + 'file', + 'folder', + 'message', + 'page', + 'memory', + 'mcp', + 'plugin', + 'communityAgent', + 'knowledgeBase', +] as const; + +type SearchType = (typeof SEARCH_TYPES)[number]; + +function renderResultGroup(type: string, items: any[]) { + if (items.length === 0) return; + + console.log(); + console.log(pc.bold(pc.cyan(`── ${type} (${items.length}) ──`))); + + const rows = items.map((item: any) => [ + item.id || '', + truncate(item.title || item.name || item.content || 'Untitled', 80), + item.description ? truncate(item.description, 40) : '', + ]); + + printTable(rows, ['ID', 'TITLE', 'DESCRIPTION']); +} + +export function registerSearchCommand(program: Command) { + program + .command('search <query>') + .description('Search across topics, agents, files, knowledge bases, and more') + .option('-t, --type <type>', `Filter by type: ${SEARCH_TYPES.join(', ')}`) + .option('-L, --limit <n>', 'Results per type', '10') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action( + async ( + query: string, + options: { json?: string | boolean; limit?: string; type?: string }, + ) => { + if (options.type && !SEARCH_TYPES.includes(options.type as SearchType)) { + console.error( + `Invalid type: ${options.type}. Must be one of: ${SEARCH_TYPES.join(', ')}`, + ); + process.exit(1); + } + + const client = await getTrpcClient(); + + const input: { limitPerType?: number; query: string; type?: SearchType } = { query }; + if (options.type) input.type = options.type as SearchType; + if (options.limit) input.limitPerType = Number.parseInt(options.limit, 10); + + const result = await client.search.query.query(input); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + // result is expected to be an object grouped by type or an array + if (Array.isArray(result)) { + if (result.length === 0) { + console.log('No results found.'); + return; + } + // Group by type if available + const groups: Record<string, any[]> = {}; + for (const item of result) { + const t = item.type || 'other'; + if (!groups[t]) groups[t] = []; + groups[t].push(item); + } + for (const [type, items] of Object.entries(groups)) { + renderResultGroup(type, items); + } + } else if (result && typeof result === 'object') { + const groups = result as Record<string, any[]>; + let hasResults = false; + for (const [type, items] of Object.entries(groups)) { + if (Array.isArray(items) && items.length > 0) { + hasResults = true; + renderResultGroup(type, items); + } + } + if (!hasResults) { + console.log('No results found.'); + } + } + }, + ); +} diff --git a/apps/cli/src/commands/skill.test.ts b/apps/cli/src/commands/skill.test.ts new file mode 100644 index 0000000000..2db2e0202b --- /dev/null +++ b/apps/cli/src/commands/skill.test.ts @@ -0,0 +1,329 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerSkillCommand } from './skill'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + agentSkills: { + create: { mutate: vi.fn() }, + delete: { mutate: vi.fn() }, + getById: { query: vi.fn() }, + importFromGitHub: { mutate: vi.fn() }, + importFromMarket: { mutate: vi.fn() }, + importFromUrl: { mutate: vi.fn() }, + list: { query: vi.fn() }, + listResources: { query: vi.fn() }, + readResource: { query: vi.fn() }, + search: { query: vi.fn() }, + update: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('skill command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + let stdoutSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.agentSkills)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + stdoutSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerSkillCommand(program); + return program; + } + + describe('list', () => { + it('should display skills in table format', async () => { + mockTrpcClient.agentSkills.list.query.mockResolvedValue([ + { + description: 'A skill', + id: 's1', + identifier: 'test-skill', + name: 'Test Skill', + source: 'user', + }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row + expect(consoleSpy.mock.calls[0][0]).toContain('ID'); + }); + + it('should output JSON when --json flag is used', async () => { + const items = [{ id: 's1', name: 'Test' }]; + mockTrpcClient.agentSkills.list.query.mockResolvedValue(items); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'list', '--json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2)); + }); + + it('should filter by source', async () => { + mockTrpcClient.agentSkills.list.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'list', '--source', 'builtin']); + + expect(mockTrpcClient.agentSkills.list.query).toHaveBeenCalledWith( + expect.objectContaining({ source: 'builtin' }), + ); + }); + + it('should reject invalid source', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'list', '--source', 'invalid']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid source')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('should show message when no skills found', async () => { + mockTrpcClient.agentSkills.list.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'list']); + + expect(consoleSpy).toHaveBeenCalledWith('No skills found.'); + }); + }); + + describe('view', () => { + it('should display skill details', async () => { + mockTrpcClient.agentSkills.getById.query.mockResolvedValue({ + content: 'Skill content here', + description: 'A test skill', + id: 's1', + name: 'Test Skill', + source: 'user', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'view', 's1']); + + expect(mockTrpcClient.agentSkills.getById.query).toHaveBeenCalledWith({ id: 's1' }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test Skill')); + }); + + it('should exit when not found', async () => { + mockTrpcClient.agentSkills.getById.query.mockResolvedValue(null); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'view', 'nonexistent']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('create', () => { + it('should create a skill', async () => { + mockTrpcClient.agentSkills.create.mutate.mockResolvedValue({ id: 'new-skill' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'skill', + 'create', + '--name', + 'My Skill', + '--description', + 'A skill', + '--content', + 'Do something', + ]); + + expect(mockTrpcClient.agentSkills.create.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Do something', + description: 'A skill', + name: 'My Skill', + }), + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('new-skill')); + }); + }); + + describe('edit', () => { + it('should update skill content', async () => { + mockTrpcClient.agentSkills.update.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'edit', 's1', '--content', 'updated']); + + expect(mockTrpcClient.agentSkills.update.mutate).toHaveBeenCalledWith( + expect.objectContaining({ content: 'updated', id: 's1' }), + ); + }); + + it('should exit when no changes specified', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'edit', 's1']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('delete', () => { + it('should delete with --yes', async () => { + mockTrpcClient.agentSkills.delete.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'delete', 's1', '--yes']); + + expect(mockTrpcClient.agentSkills.delete.mutate).toHaveBeenCalledWith({ id: 's1' }); + }); + }); + + describe('search', () => { + it('should search skills', async () => { + mockTrpcClient.agentSkills.search.query.mockResolvedValue([ + { description: 'A skill', id: 's1', name: 'Found Skill' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'search', 'test']); + + expect(mockTrpcClient.agentSkills.search.query).toHaveBeenCalledWith({ query: 'test' }); + expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row + }); + + it('should show message when no results', async () => { + mockTrpcClient.agentSkills.search.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'search', 'nothing']); + + expect(consoleSpy).toHaveBeenCalledWith('No skills found.'); + }); + }); + + describe('import-github', () => { + it('should import from GitHub', async () => { + mockTrpcClient.agentSkills.importFromGitHub.mutate.mockResolvedValue({ + id: 'imported', + name: 'GH Skill', + }); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'skill', + 'import-github', + '--url', + 'https://github.com/user/repo', + ]); + + expect(mockTrpcClient.agentSkills.importFromGitHub.mutate).toHaveBeenCalledWith( + expect.objectContaining({ gitUrl: 'https://github.com/user/repo' }), + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Imported')); + }); + }); + + describe('import-market', () => { + it('should install from marketplace', async () => { + mockTrpcClient.agentSkills.importFromMarket.mutate.mockResolvedValue({ id: 'mk1' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', + 'test', + 'skill', + 'import-market', + '--identifier', + 'some-skill', + ]); + + expect(mockTrpcClient.agentSkills.importFromMarket.mutate).toHaveBeenCalledWith({ + identifier: 'some-skill', + }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('some-skill')); + }); + }); + + describe('resources', () => { + it('should list resources', async () => { + mockTrpcClient.agentSkills.listResources.query.mockResolvedValue([ + { name: 'file.txt', size: 1024, type: 'text' }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'resources', 's1']); + + expect(mockTrpcClient.agentSkills.listResources.query).toHaveBeenCalledWith({ id: 's1' }); + expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row + }); + + it('should show message when no resources', async () => { + mockTrpcClient.agentSkills.listResources.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'resources', 's1']); + + expect(consoleSpy).toHaveBeenCalledWith('No resources found.'); + }); + }); + + describe('read-resource', () => { + it('should output resource content', async () => { + mockTrpcClient.agentSkills.readResource.query.mockResolvedValue({ + content: 'file contents here', + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'read-resource', 's1', 'file.txt']); + + expect(mockTrpcClient.agentSkills.readResource.query).toHaveBeenCalledWith({ + id: 's1', + path: 'file.txt', + }); + expect(stdoutSpy).toHaveBeenCalledWith('file contents here'); + }); + + it('should exit when resource not found', async () => { + mockTrpcClient.agentSkills.readResource.query.mockResolvedValue(null); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'skill', 'read-resource', 's1', 'missing.txt']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/apps/cli/src/commands/skill.ts b/apps/cli/src/commands/skill.ts new file mode 100644 index 0000000000..afae9a9367 --- /dev/null +++ b/apps/cli/src/commands/skill.ts @@ -0,0 +1,315 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerSkillCommand(program: Command) { + const skill = program.command('skill').description('Manage agent skills'); + + // ── list ────────────────────────────────────────────── + + skill + .command('list') + .description('List skills') + .option('--source <source>', 'Filter by source: builtin, market, user') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean; source?: string }) => { + if (options.source && !['builtin', 'market', 'user'].includes(options.source)) { + log.error('Invalid source. Must be one of: builtin, market, user'); + process.exit(1); + return; + } + + const client = await getTrpcClient(); + + const input: { source?: 'builtin' | 'market' | 'user' } = {}; + if (options.source) input.source = options.source as 'builtin' | 'market' | 'user'; + + const result = await client.agentSkills.list.query(input); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No skills found.'); + return; + } + + const rows = items.map((s: any) => [ + s.id || '', + truncate(s.name || '', 30), + truncate(s.description || '', 40), + s.source || '', + s.identifier || '', + ]); + + printTable(rows, ['ID', 'NAME', 'DESCRIPTION', 'SOURCE', 'IDENTIFIER']); + }); + + // ── view ────────────────────────────────────────────── + + skill + .command('view <id>') + .description('View skill details') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (id: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.agentSkills.getById.query({ id }); + + if (!result) { + log.error(`Skill not found: ${id}`); + process.exit(1); + return; + } + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(result, fields); + return; + } + + const r = result as any; + console.log(pc.bold(r.name || 'Untitled')); + const meta: string[] = []; + if (r.description) meta.push(r.description); + if (r.source) meta.push(`Source: ${r.source}`); + if (r.identifier) meta.push(`ID: ${r.identifier}`); + if (meta.length > 0) console.log(pc.dim(meta.join(' · '))); + + if (r.content) { + console.log(); + console.log(pc.bold('Content:')); + console.log(r.content); + } + }); + + // ── create ──────────────────────────────────────────── + + skill + .command('create') + .description('Create a user skill') + .requiredOption('-n, --name <name>', 'Skill name') + .requiredOption('-d, --description <desc>', 'Skill description') + .requiredOption('-c, --content <content>', 'Skill content (prompt)') + .option('-i, --identifier <id>', 'Custom identifier') + .action( + async (options: { + content: string; + description: string; + identifier?: string; + name: string; + }) => { + const client = await getTrpcClient(); + + const input: { + content: string; + description: string; + identifier?: string; + name: string; + } = { + content: options.content, + description: options.description, + name: options.name, + }; + if (options.identifier) input.identifier = options.identifier; + + const result = await client.agentSkills.create.mutate(input); + const r = result as any; + console.log(`${pc.green('✓')} Created skill ${pc.bold(r.id || r)}`); + }, + ); + + // ── edit ────────────────────────────────────────────── + + skill + .command('edit <id>') + .description('Update a skill') + .option('-c, --content <content>', 'New content') + .option('-n, --name <name>', 'New name (via manifest)') + .option('-d, --description <desc>', 'New description (via manifest)') + .action( + async (id: string, options: { content?: string; description?: string; name?: string }) => { + if (!options.content && !options.name && !options.description) { + log.error('No changes specified. Use --content, --name, or --description.'); + process.exit(1); + return; + } + + const client = await getTrpcClient(); + + const input: Record<string, any> = { id }; + if (options.content) input.content = options.content; + + if (options.name || options.description) { + const manifest: Record<string, any> = {}; + if (options.name) manifest.name = options.name; + if (options.description) manifest.description = options.description; + input.manifest = manifest; + } + + await client.agentSkills.update.mutate(input as any); + console.log(`${pc.green('✓')} Updated skill ${pc.bold(id)}`); + }, + ); + + // ── delete ──────────────────────────────────────────── + + skill + .command('delete <id>') + .description('Delete a skill') + .option('--yes', 'Skip confirmation prompt') + .action(async (id: string, options: { yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm('Are you sure you want to delete this skill?'); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + await client.agentSkills.delete.mutate({ id }); + console.log(`${pc.green('✓')} Deleted skill ${pc.bold(id)}`); + }); + + // ── search ──────────────────────────────────────────── + + skill + .command('search <query>') + .description('Search skills') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (query: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.agentSkills.search.query({ query }); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No skills found.'); + return; + } + + const rows = items.map((s: any) => [ + s.id || '', + truncate(s.name || '', 30), + truncate(s.description || '', 50), + ]); + + printTable(rows, ['ID', 'NAME', 'DESCRIPTION']); + }); + + // ── import-github ───────────────────────────────────── + + skill + .command('import-github') + .description('Import a skill from GitHub') + .requiredOption('--url <gitUrl>', 'GitHub repository URL') + .option('--branch <branch>', 'Branch name') + .action(async (options: { branch?: string; url: string }) => { + const client = await getTrpcClient(); + + const input: { branch?: string; gitUrl: string } = { gitUrl: options.url }; + if (options.branch) input.branch = options.branch; + + const result = await client.agentSkills.importFromGitHub.mutate(input); + const r = result as any; + console.log(`${pc.green('✓')} Imported skill from GitHub ${pc.bold(r.id || r.name || '')}`); + }); + + // ── import-url ──────────────────────────────────────── + + skill + .command('import-url') + .description('Import a skill from a ZIP URL') + .requiredOption('--url <zipUrl>', 'URL to skill ZIP file') + .action(async (options: { url: string }) => { + const client = await getTrpcClient(); + + const result = await client.agentSkills.importFromUrl.mutate({ url: options.url }); + const r = result as any; + console.log(`${pc.green('✓')} Imported skill from URL ${pc.bold(r.id || r.name || '')}`); + }); + + // ── import-market ───────────────────────────────────── + + skill + .command('import-market') + .description('Install a skill from the marketplace') + .requiredOption('-i, --identifier <id>', 'Skill identifier in marketplace') + .action(async (options: { identifier: string }) => { + const client = await getTrpcClient(); + + const result = await client.agentSkills.importFromMarket.mutate({ + identifier: options.identifier, + }); + const r = result as any; + console.log( + `${pc.green('✓')} Installed skill ${pc.bold(options.identifier)} ${r.id ? `(${r.id})` : ''}`, + ); + }); + + // ── resources ───────────────────────────────────────── + + skill + .command('resources <id>') + .description('List skill resource files') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (id: string, options: { json?: string | boolean }) => { + const client = await getTrpcClient(); + const result = await client.agentSkills.listResources.query({ id }); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No resources found.'); + return; + } + + const rows = items.map((r: any) => [ + truncate(r.path || r.name || '', 60), + r.type || '', + r.size ? `${Math.round(r.size / 1024)}KB` : '', + ]); + + printTable(rows, ['PATH', 'TYPE', 'SIZE']); + }); + + // ── read-resource ───────────────────────────────────── + + skill + .command('read-resource <id> <path>') + .description('Read a skill resource file') + .action(async (id: string, path: string) => { + const client = await getTrpcClient(); + const result = await client.agentSkills.readResource.query({ id, path }); + + if (!result) { + log.error(`Resource not found: ${path}`); + process.exit(1); + return; + } + + const r = result as any; + if (r.content) { + process.stdout.write(r.content); + } else { + console.log(JSON.stringify(result, null, 2)); + } + }); +} diff --git a/apps/cli/src/commands/topic.test.ts b/apps/cli/src/commands/topic.test.ts new file mode 100644 index 0000000000..e3f4f70747 --- /dev/null +++ b/apps/cli/src/commands/topic.test.ts @@ -0,0 +1,164 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { log } from '../utils/logger'; +import { registerTopicCommand } from './topic'; + +const { mockTrpcClient } = vi.hoisted(() => ({ + mockTrpcClient: { + topic: { + batchDelete: { mutate: vi.fn() }, + createTopic: { mutate: vi.fn() }, + getTopics: { query: vi.fn() }, + recentTopics: { query: vi.fn() }, + removeTopic: { mutate: vi.fn() }, + searchTopics: { query: vi.fn() }, + updateTopic: { mutate: vi.fn() }, + }, + }, +})); + +const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({ + getTrpcClient: vi.fn(), +})); + +vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient })); +vi.mock('../utils/logger', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + setVerbose: vi.fn(), +})); + +describe('topic command', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let consoleSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetTrpcClient.mockResolvedValue(mockTrpcClient); + for (const method of Object.values(mockTrpcClient.topic)) { + for (const fn of Object.values(method)) { + (fn as ReturnType<typeof vi.fn>).mockReset(); + } + } + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + function createProgram() { + const program = new Command(); + program.exitOverride(); + registerTopicCommand(program); + return program; + } + + describe('list', () => { + it('should display topics', async () => { + mockTrpcClient.topic.getTopics.query.mockResolvedValue([ + { id: 't1', title: 'Topic 1', updatedAt: new Date().toISOString() }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'list']); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + it('should filter by agent-id', async () => { + mockTrpcClient.topic.getTopics.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1']); + + expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'a1' }), + ); + }); + }); + + describe('search', () => { + it('should search topics', async () => { + mockTrpcClient.topic.searchTopics.query.mockResolvedValue([]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'search', 'hello']); + + expect(mockTrpcClient.topic.searchTopics.query).toHaveBeenCalledWith( + expect.objectContaining({ keywords: 'hello' }), + ); + }); + }); + + describe('create', () => { + it('should create a topic', async () => { + mockTrpcClient.topic.createTopic.mutate.mockResolvedValue({ id: 't-new' }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'create', '-t', 'New Topic']); + + expect(mockTrpcClient.topic.createTopic.mutate).toHaveBeenCalledWith( + expect.objectContaining({ title: 'New Topic' }), + ); + }); + }); + + describe('edit', () => { + it('should update a topic', async () => { + mockTrpcClient.topic.updateTopic.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'edit', 't1', '-t', 'Updated']); + + expect(mockTrpcClient.topic.updateTopic.mutate).toHaveBeenCalledWith({ + id: 't1', + value: { title: 'Updated' }, + }); + }); + + it('should exit when no changes', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'edit', 't1']); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('delete', () => { + it('should delete single topic', async () => { + mockTrpcClient.topic.removeTopic.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'delete', 't1', '--yes']); + + expect(mockTrpcClient.topic.removeTopic.mutate).toHaveBeenCalledWith({ id: 't1' }); + }); + + it('should batch delete multiple topics', async () => { + mockTrpcClient.topic.batchDelete.mutate.mockResolvedValue({}); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'delete', 't1', 't2', '--yes']); + + expect(mockTrpcClient.topic.batchDelete.mutate).toHaveBeenCalledWith({ + ids: ['t1', 't2'], + }); + }); + }); + + describe('recent', () => { + it('should list recent topics', async () => { + mockTrpcClient.topic.recentTopics.query.mockResolvedValue([ + { id: 't1', title: 'Recent', updatedAt: new Date().toISOString() }, + ]); + + const program = createProgram(); + await program.parseAsync(['node', 'test', 'topic', 'recent']); + + expect(mockTrpcClient.topic.recentTopics.query).toHaveBeenCalledWith({ limit: 10 }); + }); + }); +}); diff --git a/apps/cli/src/commands/topic.ts b/apps/cli/src/commands/topic.ts new file mode 100644 index 0000000000..d79faaf1e6 --- /dev/null +++ b/apps/cli/src/commands/topic.ts @@ -0,0 +1,205 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; + +import { getTrpcClient } from '../api/client'; +import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format'; +import { log } from '../utils/logger'; + +export function registerTopicCommand(program: Command) { + const topic = program.command('topic').description('Manage conversation topics'); + + // ── list ────────────────────────────────────────────── + + topic + .command('list') + .description('List topics') + .option('--agent-id <id>', 'Filter by agent ID') + .option('--session-id <id>', 'Filter by session ID') + .option('-L, --limit <n>', 'Page size', '30') + .option('--page <n>', 'Page number', '1') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action( + async (options: { + agentId?: string; + json?: string | boolean; + limit?: string; + page?: string; + sessionId?: string; + }) => { + const client = await getTrpcClient(); + + const input: Record<string, any> = {}; + if (options.agentId) input.agentId = options.agentId; + if (options.sessionId) input.sessionId = options.sessionId; + if (options.limit) input.pageSize = Number.parseInt(options.limit, 10); + if (options.page) input.current = Number.parseInt(options.page, 10); + + const result = await client.topic.getTopics.query(input as any); + const items = Array.isArray(result) ? result : ((result as any).items ?? []); + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No topics found.'); + return; + } + + const rows = items.map((t: any) => [ + t.id || '', + truncate(t.title || 'Untitled', 50), + t.favorite ? '★' : '', + t.updatedAt ? timeAgo(t.updatedAt) : '', + ]); + + printTable(rows, ['ID', 'TITLE', 'FAV', 'UPDATED']); + }, + ); + + // ── search ──────────────────────────────────────────── + + topic + .command('search <keywords>') + .description('Search topics') + .option('--agent-id <id>', 'Filter by agent ID') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (keywords: string, options: { agentId?: string; json?: string | boolean }) => { + const client = await getTrpcClient(); + + const input: Record<string, any> = { keywords }; + if (options.agentId) input.agentId = options.agentId; + + const result = await client.topic.searchTopics.query(input as any); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No topics found.'); + return; + } + + const rows = items.map((t: any) => [t.id || '', truncate(t.title || 'Untitled', 50)]); + + printTable(rows, ['ID', 'TITLE']); + }); + + // ── create ──────────────────────────────────────────── + + topic + .command('create') + .description('Create a topic') + .requiredOption('-t, --title <title>', 'Topic title') + .option('--agent-id <id>', 'Agent ID') + .option('--session-id <id>', 'Session ID') + .option('--favorite', 'Mark as favorite') + .action( + async (options: { + agentId?: string; + favorite?: boolean; + sessionId?: string; + title: string; + }) => { + const client = await getTrpcClient(); + + const input: Record<string, any> = { title: options.title }; + if (options.agentId) input.agentId = options.agentId; + if (options.sessionId) input.sessionId = options.sessionId; + if (options.favorite) input.favorite = true; + + const result = await client.topic.createTopic.mutate(input as any); + const r = result as any; + console.log(`${pc.green('✓')} Created topic ${pc.bold(r.id || r)}`); + }, + ); + + // ── edit ────────────────────────────────────────────── + + topic + .command('edit <id>') + .description('Update a topic') + .option('-t, --title <title>', 'New title') + .option('--favorite', 'Mark as favorite') + .option('--no-favorite', 'Unmark as favorite') + .action(async (id: string, options: { favorite?: boolean; title?: string }) => { + const value: Record<string, any> = {}; + if (options.title) value.title = options.title; + if (options.favorite !== undefined) value.favorite = options.favorite; + + if (Object.keys(value).length === 0) { + log.error('No changes specified. Use --title or --favorite.'); + process.exit(1); + } + + const client = await getTrpcClient(); + await client.topic.updateTopic.mutate({ id, value }); + console.log(`${pc.green('✓')} Updated topic ${pc.bold(id)}`); + }); + + // ── delete ──────────────────────────────────────────── + + topic + .command('delete <ids...>') + .description('Delete one or more topics') + .option('--yes', 'Skip confirmation prompt') + .action(async (ids: string[], options: { yes?: boolean }) => { + if (!options.yes) { + const confirmed = await confirm(`Are you sure you want to delete ${ids.length} topic(s)?`); + if (!confirmed) { + console.log('Cancelled.'); + return; + } + } + + const client = await getTrpcClient(); + + if (ids.length === 1) { + await client.topic.removeTopic.mutate({ id: ids[0] }); + } else { + await client.topic.batchDelete.mutate({ ids }); + } + + console.log(`${pc.green('✓')} Deleted ${ids.length} topic(s)`); + }); + + // ── recent ──────────────────────────────────────────── + + topic + .command('recent') + .description('List recent topics') + .option('-L, --limit <n>', 'Number of items', '10') + .option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)') + .action(async (options: { json?: string | boolean; limit?: string }) => { + const client = await getTrpcClient(); + const limit = Number.parseInt(options.limit || '10', 10); + + const result = await client.topic.recentTopics.query({ limit }); + const items = Array.isArray(result) ? result : []; + + if (options.json !== undefined) { + const fields = typeof options.json === 'string' ? options.json : undefined; + outputJson(items, fields); + return; + } + + if (items.length === 0) { + console.log('No recent topics.'); + return; + } + + const rows = items.map((t: any) => [ + t.id || '', + truncate(t.title || 'Untitled', 50), + t.updatedAt ? timeAgo(t.updatedAt) : '', + ]); + + printTable(rows, ['ID', 'TITLE', 'UPDATED']); + }); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d9d6fe9dbf..d597a561d0 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,10 +1,22 @@ import { Command } from 'commander'; +import { registerAgentCommand } from './commands/agent'; +import { registerConfigCommand } from './commands/config'; import { registerConnectCommand } from './commands/connect'; import { registerDocCommand } from './commands/doc'; +import { registerFileCommand } from './commands/file'; +import { registerKbCommand } from './commands/kb'; import { registerLoginCommand } from './commands/login'; import { registerLogoutCommand } from './commands/logout'; +import { registerMemoryCommand } from './commands/memory'; +import { registerMessageCommand } from './commands/message'; +import { registerModelCommand } from './commands/model'; +import { registerPluginCommand } from './commands/plugin'; +import { registerProviderCommand } from './commands/provider'; +import { registerSearchCommand } from './commands/search'; +import { registerSkillCommand } from './commands/skill'; import { registerStatusCommand } from './commands/status'; +import { registerTopicCommand } from './commands/topic'; const program = new Command(); @@ -18,5 +30,17 @@ registerLogoutCommand(program); registerConnectCommand(program); registerStatusCommand(program); registerDocCommand(program); +registerSearchCommand(program); +registerKbCommand(program); +registerMemoryCommand(program); +registerAgentCommand(program); +registerFileCommand(program); +registerSkillCommand(program); +registerTopicCommand(program); +registerMessageCommand(program); +registerModelCommand(program); +registerProviderCommand(program); +registerPluginCommand(program); +registerConfigCommand(program); program.parse(); diff --git a/apps/cli/src/utils/format.ts b/apps/cli/src/utils/format.ts new file mode 100644 index 0000000000..5eda2a63d6 --- /dev/null +++ b/apps/cli/src/utils/format.ts @@ -0,0 +1,71 @@ +import { createInterface } from 'node:readline'; + +import pc from 'picocolors'; + +export function timeAgo(date: Date | string): string { + const diff = Date.now() - new Date(date).getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return `${seconds}s ago`; +} + +export function truncate(str: string, len: number): string { + if (str.length <= len) return str; + return str.slice(0, len - 1) + '…'; +} + +export function printTable(rows: string[][], header: string[]) { + const allRows = [header, ...rows]; + const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => (r[i] || '').length))); + + const headerLine = header.map((h, i) => h.padEnd(colWidths[i])).join(' '); + console.log(pc.bold(headerLine)); + + for (const row of rows) { + const line = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' '); + console.log(line); + } +} + +export function pickFields(obj: Record<string, any>, fields: string[]): Record<string, any> { + const result: Record<string, any> = {}; + for (const f of fields) { + if (f in obj) result[f] = obj[f]; + } + return result; +} + +export function outputJson(data: unknown, fields?: string) { + if (fields) { + const fieldList = fields.split(',').map((f) => f.trim()); + if (Array.isArray(data)) { + console.log( + JSON.stringify( + data.map((item) => pickFields(item, fieldList)), + null, + 2, + ), + ); + } else if (data && typeof data === 'object') { + console.log(JSON.stringify(pickFields(data as Record<string, any>, fieldList), null, 2)); + } + } else { + console.log(JSON.stringify(data, null, 2)); + } +} + +export function confirm(message: string): Promise<boolean> { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(`${message} (y/N) `, (answer: string) => { + rl.close(); + resolve(answer.toLowerCase() === 'y'); + }); + }); +}