mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat(cli): add full API integration commands In cli (#12795)
* ✨ feat(cli): add full API integration commands Add comprehensive CLI commands for managing LobeHub resources: P0 - Search, Knowledge Base, Memory: - `lh search` - Global unified search across all resource types - `lh kb` - Knowledge base CRUD, file management - `lh memory` - User memory CRUD (identity/activity/context/experience/preference), persona, extraction P1 - Agent, Session, Topic, Message: - `lh agent` - Agent CRUD (list/view/create/edit/delete/duplicate) - `lh session` - Session management with search - `lh topic` - Topic CRUD with search and recent - `lh message` - Message listing, search, delete, count, heatmap P2 - Model, Provider: - `lh model` - Model listing, toggle, delete per provider - `lh provider` - Provider listing, toggle, delete P3 - Plugin, Config: - `lh plugin` - Plugin install/uninstall/update - `lh whoami` - User info display - `lh usage` - Usage statistics (monthly/daily) Also refactors shared formatting utilities into utils/format.ts. All commands support `--json` output for scripting. Closes LOBE-5706, LOBE-5770 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ✨ feat(cli): add file/skill commands, remove session, split kb - Add standalone `file` command (list, view, delete, recent) - Add `skill` command (list, view, create, edit, delete, search, import, resources) - Remove `session` command (no longer needed) - Remove `files` subcommand from `kb` (now separate `file` command) - Add tests for file and skill commands - Register new commands in index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 🐛 fix(cli): fix ESM require in confirm, login unhandled rejections, memory create - Replace CommonJS require('node:readline') with ESM import in confirm helper - Add return after process.exit(1) in login.ts to prevent unhandled rejections - Simplify memory create to only support identity (other categories lack create procedures) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
189
apps/cli/src/commands/agent.test.ts
Normal file
189
apps/cli/src/commands/agent.test.ts
Normal file
@@ -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<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.agent)) {
|
||||
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();
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
202
apps/cli/src/commands/agent.ts
Normal file
202
apps/cli/src/commands/agent.ts
Normal file
@@ -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 <n>', 'Maximum number of items', '30')
|
||||
.option('-k, --keyword <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 <agentId>')
|
||||
.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 <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')}`);
|
||||
});
|
||||
}
|
||||
117
apps/cli/src/commands/config.test.ts
Normal file
117
apps/cli/src/commands/config.test.ts
Normal file
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
78
apps/cli/src/commands/config.ts
Normal file
78
apps/cli/src/commands/config.ts
Normal file
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
177
apps/cli/src/commands/file.test.ts
Normal file
177
apps/cli/src/commands/file.test.ts
Normal file
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
147
apps/cli/src/commands/file.ts
Normal file
147
apps/cli/src/commands/file.ts
Normal file
@@ -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']);
|
||||
});
|
||||
}
|
||||
206
apps/cli/src/commands/kb.test.ts
Normal file
206
apps/cli/src/commands/kb.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
196
apps/cli/src/commands/kb.ts
Normal file
196
apps/cli/src/commands/kb.ts
Normal file
@@ -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)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
215
apps/cli/src/commands/memory.test.ts
Normal file
215
apps/cli/src/commands/memory.test.ts
Normal file
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
345
apps/cli/src/commands/memory.ts
Normal file
345
apps/cli/src/commands/memory.ts
Normal file
@@ -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;
|
||||
}
|
||||
134
apps/cli/src/commands/message.test.ts
Normal file
134
apps/cli/src/commands/message.test.ts
Normal file
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
171
apps/cli/src/commands/message.ts
Normal file
171
apps/cli/src/commands/message.ts
Normal file
@@ -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))}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
140
apps/cli/src/commands/model.test.ts
Normal file
140
apps/cli/src/commands/model.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
133
apps/cli/src/commands/model.ts
Normal file
133
apps/cli/src/commands/model.ts
Normal file
@@ -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)}`);
|
||||
});
|
||||
}
|
||||
159
apps/cli/src/commands/plugin.test.ts
Normal file
159
apps/cli/src/commands/plugin.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
apps/cli/src/commands/plugin.ts
Normal file
146
apps/cli/src/commands/plugin.ts
Normal file
@@ -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)}`);
|
||||
});
|
||||
}
|
||||
128
apps/cli/src/commands/provider.test.ts
Normal file
128
apps/cli/src/commands/provider.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
112
apps/cli/src/commands/provider.ts
Normal file
112
apps/cli/src/commands/provider.ts
Normal file
@@ -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)}`);
|
||||
});
|
||||
}
|
||||
133
apps/cli/src/commands/search.test.ts
Normal file
133
apps/cli/src/commands/search.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
102
apps/cli/src/commands/search.ts
Normal file
102
apps/cli/src/commands/search.ts
Normal file
@@ -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.');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
329
apps/cli/src/commands/skill.test.ts
Normal file
329
apps/cli/src/commands/skill.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
315
apps/cli/src/commands/skill.ts
Normal file
315
apps/cli/src/commands/skill.ts
Normal file
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
164
apps/cli/src/commands/topic.test.ts
Normal file
164
apps/cli/src/commands/topic.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
205
apps/cli/src/commands/topic.ts
Normal file
205
apps/cli/src/commands/topic.ts
Normal file
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
71
apps/cli/src/utils/format.ts
Normal file
71
apps/cli/src/utils/format.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user