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:
Arvin Xu
2026-03-08 00:18:01 +08:00
committed by GitHub
parent e48fd47d4e
commit 6acba612fc
29 changed files with 4346 additions and 85 deletions

View 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' }),
);
});
});
});

View 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')}`);
});
}

View 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));
});
});
});

View 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));
}
});
}

View File

@@ -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) {

View 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.');
});
});
});

View 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']);
});
}

View 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
View 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)}`,
);
});
}

View File

@@ -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);

View File

@@ -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) {

View 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'));
});
});
});

View 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;
}

View 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 }));
});
});
});

View 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))}`);
}
});
}

View 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',
});
});
});
});

View 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)}`);
});
}

View 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);
});
});
});

View 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)}`);
});
}

View 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',
});
});
});
});

View 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)}`);
});
}

View 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();
});
});

View 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.');
}
}
},
);
}

View 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);
});
});
});

View 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));
}
});
}

View 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 });
});
});
});

View 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']);
});
}

View File

@@ -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();

View 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');
});
});
}