feat(cli): CLI Phase 4 - cron, message, topic share, agent-group, session-group (#12915)

*  feat(cli): CLI Phase 4 - cron, message enhance, topic share, agent-group, session-group

Add core commands to complete CLI coverage of TRPC routers:

- `lh cron` — Agent cron job management (list/view/create/edit/delete/toggle/reset/stats)
- `lh message` — Enhanced with create/edit/add-files/word-count/rank-models/delete-by-assistant/delete-by-group
- `lh topic` — Enhanced with clone/share/unshare/share-info/import
- `lh agent-group` — Agent group management (list/view/create/edit/delete/duplicate/add-agents/remove-agents)
- `lh session-group` — Session group management (list/create/edit/delete/sort)

Closes LOBE-5920

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update version

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-03-12 00:32:00 +08:00
committed by GitHub
parent 14dd5d09dd
commit 165697ce47
10 changed files with 1400 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.10",
"version": "0.0.1-canary.11",
"type": "module",
"bin": {
"lh": "./dist/index.js",

View File

@@ -0,0 +1,178 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerAgentGroupCommand } from './agent-group';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
group: {
addAgentsToGroup: { mutate: vi.fn() },
createGroup: { mutate: vi.fn() },
deleteGroup: { mutate: vi.fn() },
duplicateGroup: { mutate: vi.fn() },
getGroupDetail: { query: vi.fn() },
getGroups: { query: vi.fn() },
removeAgentsFromGroup: { mutate: vi.fn() },
updateGroup: { 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-group 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.group)) {
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();
registerAgentGroupCommand(program);
return program;
}
describe('list', () => {
it('should list agent groups', async () => {
mockTrpcClient.group.getGroups.query.mockResolvedValue([
{ agents: [{ id: 'a1' }], id: 'g1', title: 'Group 1' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent-group', 'list']);
expect(mockTrpcClient.group.getGroups.query).toHaveBeenCalled();
});
it('should show empty message when no groups', async () => {
mockTrpcClient.group.getGroups.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent-group', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No agent groups found.');
});
});
describe('view', () => {
it('should view group details', async () => {
mockTrpcClient.group.getGroupDetail.query.mockResolvedValue({
agents: [{ id: 'a1', title: 'Agent 1' }],
id: 'g1',
title: 'Group 1',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent-group', 'view', 'g1']);
expect(mockTrpcClient.group.getGroupDetail.query).toHaveBeenCalledWith({ id: 'g1' });
});
});
describe('create', () => {
it('should create a group', async () => {
mockTrpcClient.group.createGroup.mutate.mockResolvedValue({ group: { id: 'g1' } });
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent-group', 'create', '-t', 'My Group']);
expect(mockTrpcClient.group.createGroup.mutate).toHaveBeenCalledWith(
expect.objectContaining({ title: 'My Group' }),
);
});
});
describe('delete', () => {
it('should delete a group', async () => {
mockTrpcClient.group.deleteGroup.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent-group', 'delete', 'g1', '--yes']);
expect(mockTrpcClient.group.deleteGroup.mutate).toHaveBeenCalledWith({ id: 'g1' });
});
});
describe('duplicate', () => {
it('should duplicate a group', async () => {
mockTrpcClient.group.duplicateGroup.mutate.mockResolvedValue({ groupId: 'g2' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent-group', 'duplicate', 'g1', '-t', 'Copy']);
expect(mockTrpcClient.group.duplicateGroup.mutate).toHaveBeenCalledWith({
groupId: 'g1',
newTitle: 'Copy',
});
});
});
describe('add-agents', () => {
it('should add agents to group', async () => {
mockTrpcClient.group.addAgentsToGroup.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent-group',
'add-agents',
'g1',
'--agent-ids',
'a1,a2',
]);
expect(mockTrpcClient.group.addAgentsToGroup.mutate).toHaveBeenCalledWith({
agentIds: ['a1', 'a2'],
groupId: 'g1',
});
});
});
describe('remove-agents', () => {
it('should remove agents from group', async () => {
mockTrpcClient.group.removeAgentsFromGroup.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent-group',
'remove-agents',
'g1',
'--agent-ids',
'a1',
'--yes',
]);
expect(mockTrpcClient.group.removeAgentsFromGroup.mutate).toHaveBeenCalledWith({
agentIds: ['a1'],
deleteVirtualAgents: true,
groupId: 'g1',
});
});
});
});

View File

@@ -0,0 +1,215 @@
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 registerAgentGroupCommand(program: Command) {
const agentGroup = program.command('agent-group').description('Manage agent groups');
// ── list ──────────────────────────────────────────────
agentGroup
.command('list')
.description('List all agent groups')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const groups = await client.group.getGroups.query();
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(groups, fields);
return;
}
if (!groups || (groups as any[]).length === 0) {
console.log('No agent groups found.');
return;
}
const rows = (groups as any[]).map((g: any) => [
g.id || '',
truncate(g.title || 'Untitled', 40),
String(g.agents?.length ?? 0),
]);
printTable(rows, ['ID', 'TITLE', 'AGENTS']);
});
// ── view ──────────────────────────────────────────────
agentGroup
.command('view <id>')
.description('View agent group details')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const detail = await client.group.getGroupDetail.query({ id });
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(detail, fields);
return;
}
if (!detail) {
log.error('Agent group not found.');
process.exit(1);
}
const d = detail as any;
console.log(`${pc.bold('ID:')} ${d.id}`);
console.log(`${pc.bold('Title:')} ${d.title || 'Untitled'}`);
if (d.description) console.log(`${pc.bold('Desc:')} ${d.description}`);
if (d.agents && d.agents.length > 0) {
console.log(`\n${pc.bold('Agents:')}`);
const rows = d.agents.map((a: any) => [
a.id || '',
truncate(a.title || 'Untitled', 30),
a.role || '',
a.enabled === false ? pc.dim('disabled') : pc.green('enabled'),
]);
printTable(rows, ['ID', 'TITLE', 'ROLE', 'STATUS']);
}
});
// ── create ────────────────────────────────────────────
agentGroup
.command('create')
.description('Create an agent group')
.requiredOption('-t, --title <title>', 'Group title')
.option('-d, --description <desc>', 'Group description')
.option('--json', 'Output JSON')
.action(async (options: { description?: string; json?: boolean; title: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { title: options.title };
if (options.description) input.description = options.description;
const result = await client.group.createGroup.mutate(input as any);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const r = result as any;
console.log(`${pc.green('✓')} Created agent group ${pc.bold(r.group?.id || '')}`);
});
// ── edit ───────────────────────────────────────────────
agentGroup
.command('edit <id>')
.description('Update an agent group')
.option('-t, --title <title>', 'Group title')
.option('-d, --description <desc>', 'Group description')
.action(async (id: string, options: { description?: string; title?: string }) => {
const value: Record<string, any> = {};
if (options.title) value.title = options.title;
if (options.description) value.description = options.description;
if (Object.keys(value).length === 0) {
log.error('No changes specified. Use --title or --description.');
process.exit(1);
}
const client = await getTrpcClient();
await client.group.updateGroup.mutate({ id, value } as any);
console.log(`${pc.green('✓')} Updated agent group ${pc.bold(id)}`);
});
// ── delete ────────────────────────────────────────────
agentGroup
.command('delete <id>')
.description('Delete an agent group')
.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 agent group?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.group.deleteGroup.mutate({ id });
console.log(`${pc.green('✓')} Deleted agent group ${pc.bold(id)}`);
});
// ── duplicate ─────────────────────────────────────────
agentGroup
.command('duplicate <id>')
.description('Duplicate an agent group')
.option('-t, --title <title>', 'New title for the duplicated group')
.action(async (id: string, options: { title?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { groupId: id };
if (options.title) input.newTitle = options.title;
const result = await client.group.duplicateGroup.mutate(input as any);
const r = result as any;
console.log(`${pc.green('✓')} Duplicated agent group → ${pc.bold(r.groupId || r.id || '')}`);
});
// ── add-agents ────────────────────────────────────────
agentGroup
.command('add-agents <groupId>')
.description('Add agents to a group')
.requiredOption('--agent-ids <ids>', 'Comma-separated agent IDs')
.action(async (groupId: string, options: { agentIds: string }) => {
const agentIds = options.agentIds.split(',').map((s) => s.trim());
const client = await getTrpcClient();
await client.group.addAgentsToGroup.mutate({ agentIds, groupId });
console.log(
`${pc.green('✓')} Added ${agentIds.length} agent(s) to group ${pc.bold(groupId)}`,
);
});
// ── remove-agents ─────────────────────────────────────
agentGroup
.command('remove-agents <groupId>')
.description('Remove agents from a group')
.requiredOption('--agent-ids <ids>', 'Comma-separated agent IDs')
.option('--keep-virtual', 'Keep virtual agents instead of deleting them')
.option('--yes', 'Skip confirmation prompt')
.action(
async (
groupId: string,
options: { agentIds: string; keepVirtual?: boolean; yes?: boolean },
) => {
const agentIds = options.agentIds.split(',').map((s) => s.trim());
if (!options.yes) {
const confirmed = await confirm(
`Are you sure you want to remove ${agentIds.length} agent(s) from group?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.group.removeAgentsFromGroup.mutate({
agentIds,
deleteVirtualAgents: !options.keepVirtual,
groupId,
});
console.log(
`${pc.green('✓')} Removed ${agentIds.length} agent(s) from group ${pc.bold(groupId)}`,
);
},
);
}

View File

@@ -0,0 +1,172 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerCronCommand } from './cron';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
agentCronJob: {
batchUpdateStatus: { mutate: vi.fn() },
create: { mutate: vi.fn() },
delete: { mutate: vi.fn() },
findById: { query: vi.fn() },
getStats: { query: vi.fn() },
list: { query: vi.fn() },
resetExecutions: { mutate: 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('cron 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.agentCronJob)) {
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();
registerCronCommand(program);
return program;
}
describe('list', () => {
it('should list cron jobs', async () => {
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({
data: [{ enabled: true, id: 'c1', name: 'Test Job', schedule: '* * * * *' }],
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'list']);
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalled();
});
it('should filter by agent-id', async () => {
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({ data: [] });
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'list', '--agent-id', 'a1']);
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1' }),
);
});
});
describe('view', () => {
it('should view cron job details', async () => {
mockTrpcClient.agentCronJob.findById.query.mockResolvedValue({
data: { enabled: true, id: 'c1', name: 'Test', schedule: '* * * * *' },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'view', 'c1']);
expect(mockTrpcClient.agentCronJob.findById.query).toHaveBeenCalledWith({ id: 'c1' });
});
});
describe('create', () => {
it('should create a cron job', async () => {
mockTrpcClient.agentCronJob.create.mutate.mockResolvedValue({ data: { id: 'c1' } });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'cron',
'create',
'--agent-id',
'a1',
'-s',
'* * * * *',
'-n',
'My Job',
]);
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', name: 'My Job', schedule: '* * * * *' }),
);
});
});
describe('delete', () => {
it('should delete a cron job', async () => {
mockTrpcClient.agentCronJob.delete.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'delete', 'c1', '--yes']);
expect(mockTrpcClient.agentCronJob.delete.mutate).toHaveBeenCalledWith({ id: 'c1' });
});
});
describe('toggle', () => {
it('should batch enable cron jobs', async () => {
mockTrpcClient.agentCronJob.batchUpdateStatus.mutate.mockResolvedValue({
data: { updatedCount: 2 },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'toggle', 'c1', 'c2', '--enable']);
expect(mockTrpcClient.agentCronJob.batchUpdateStatus.mutate).toHaveBeenCalledWith({
enabled: true,
ids: ['c1', 'c2'],
});
});
});
describe('reset', () => {
it('should reset execution count', async () => {
mockTrpcClient.agentCronJob.resetExecutions.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'reset', 'c1', '--max', '100']);
expect(mockTrpcClient.agentCronJob.resetExecutions.mutate).toHaveBeenCalledWith({
id: 'c1',
newMaxExecutions: 100,
});
});
});
describe('stats', () => {
it('should get stats', async () => {
mockTrpcClient.agentCronJob.getStats.query.mockResolvedValue({
data: { totalJobs: 5, totalExecutions: 100 },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'stats']);
expect(mockTrpcClient.agentCronJob.getStats.query).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,271 @@
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 registerCronCommand(program: Command) {
const cron = program.command('cron').description('Manage agent cron jobs');
// ── list ──────────────────────────────────────────────
cron
.command('list')
.description('List cron jobs')
.option('--agent-id <id>', 'Filter by agent ID')
.option('--enabled', 'Only show enabled jobs')
.option('--disabled', 'Only show disabled jobs')
.option('-L, --limit <n>', 'Page size', '20')
.option('--offset <n>', 'Offset', '0')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (options: {
agentId?: string;
disabled?: boolean;
enabled?: boolean;
json?: string | boolean;
limit?: string;
offset?: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {};
if (options.agentId) input.agentId = options.agentId;
if (options.enabled) input.enabled = true;
if (options.disabled) input.enabled = false;
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
const result = await client.agentCronJob.list.query(input as any);
const items = (result as any).data ?? [];
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(items, fields);
return;
}
if (items.length === 0) {
console.log('No cron jobs found.');
return;
}
const rows = items.map((j: any) => [
j.id || '',
truncate(j.name || '', 30),
j.schedule || '',
j.enabled ? pc.green('enabled') : pc.dim('disabled'),
`${j.executionCount ?? 0}/${j.maxExecutions ?? '∞'}`,
j.updatedAt ? timeAgo(j.updatedAt) : '',
]);
printTable(rows, ['ID', 'NAME', 'SCHEDULE', 'STATUS', 'EXECUTIONS', 'UPDATED']);
},
);
// ── view ──────────────────────────────────────────────
cron
.command('view <id>')
.description('View cron job details')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.agentCronJob.findById.query({ id });
const job = (result as any).data;
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(job, fields);
return;
}
if (!job) {
log.error('Cron job not found.');
process.exit(1);
}
console.log(`${pc.bold('ID:')} ${job.id}`);
console.log(`${pc.bold('Name:')} ${job.name || ''}`);
console.log(`${pc.bold('Agent ID:')} ${job.agentId || ''}`);
console.log(`${pc.bold('Schedule:')} ${job.schedule || ''}`);
console.log(
`${pc.bold('Status:')} ${job.enabled ? pc.green('enabled') : pc.dim('disabled')}`,
);
console.log(
`${pc.bold('Executions:')} ${job.executionCount ?? 0}/${job.maxExecutions ?? '∞'}`,
);
if (job.prompt) console.log(`${pc.bold('Prompt:')} ${truncate(job.prompt, 80)}`);
if (job.createdAt) console.log(`${pc.bold('Created:')} ${timeAgo(job.createdAt)}`);
if (job.updatedAt) console.log(`${pc.bold('Updated:')} ${timeAgo(job.updatedAt)}`);
});
// ── create ────────────────────────────────────────────
cron
.command('create')
.description('Create a cron job')
.requiredOption('--agent-id <id>', 'Agent ID')
.requiredOption('-s, --schedule <cron>', 'Cron schedule expression')
.option('-n, --name <name>', 'Job name')
.option('-p, --prompt <prompt>', 'Prompt text')
.option('--max-executions <n>', 'Maximum number of executions')
.option('--json', 'Output JSON')
.action(
async (options: {
agentId: string;
json?: boolean;
maxExecutions?: string;
name?: string;
prompt?: string;
schedule: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {
agentId: options.agentId,
schedule: options.schedule,
};
if (options.name) input.name = options.name;
if (options.prompt) input.prompt = options.prompt;
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
const result = await client.agentCronJob.create.mutate(input as any);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const data = (result as any).data;
console.log(`${pc.green('✓')} Created cron job ${pc.bold(data?.id || '')}`);
},
);
// ── edit ───────────────────────────────────────────────
cron
.command('edit <id>')
.description('Update a cron job')
.option('-n, --name <name>', 'Job name')
.option('-s, --schedule <cron>', 'Cron schedule expression')
.option('-p, --prompt <prompt>', 'Prompt text')
.option('--max-executions <n>', 'Maximum number of executions')
.option('--enable', 'Enable the job')
.option('--disable', 'Disable the job')
.action(
async (
id: string,
options: {
disable?: boolean;
enable?: boolean;
maxExecutions?: string;
name?: string;
prompt?: string;
schedule?: string;
},
) => {
const data: Record<string, any> = {};
if (options.name) data.name = options.name;
if (options.schedule) data.schedule = options.schedule;
if (options.prompt) data.prompt = options.prompt;
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
if (options.enable) data.enabled = true;
if (options.disable) data.enabled = false;
if (Object.keys(data).length === 0) {
log.error(
'No changes specified. Use --name, --schedule, --prompt, --enable, or --disable.',
);
process.exit(1);
}
const client = await getTrpcClient();
await client.agentCronJob.update.mutate({ data, id } as any);
console.log(`${pc.green('✓')} Updated cron job ${pc.bold(id)}`);
},
);
// ── delete ────────────────────────────────────────────
cron
.command('delete <id>')
.description('Delete a cron job')
.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 cron job?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.agentCronJob.delete.mutate({ id });
console.log(`${pc.green('✓')} Deleted cron job ${pc.bold(id)}`);
});
// ── toggle ────────────────────────────────────────────
cron
.command('toggle <ids...>')
.description('Batch enable or disable cron jobs')
.option('--enable', 'Enable the jobs')
.option('--disable', 'Disable the jobs')
.action(async (ids: string[], options: { disable?: boolean; enable?: boolean }) => {
if (!options.enable && !options.disable) {
log.error('Specify --enable or --disable.');
process.exit(1);
}
const enabled = !!options.enable;
const client = await getTrpcClient();
const result = await client.agentCronJob.batchUpdateStatus.mutate({ enabled, ids });
const count = (result as any).data?.updatedCount ?? ids.length;
console.log(`${pc.green('✓')} ${enabled ? 'Enabled' : 'Disabled'} ${count} cron job(s)`);
});
// ── reset ─────────────────────────────────────────────
cron
.command('reset <id>')
.description('Reset execution count for a cron job')
.option('--max <n>', 'Set new max executions')
.action(async (id: string, options: { max?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { id };
if (options.max) input.newMaxExecutions = Number.parseInt(options.max, 10);
await client.agentCronJob.resetExecutions.mutate(input as any);
console.log(`${pc.green('✓')} Reset execution count for ${pc.bold(id)}`);
});
// ── stats ─────────────────────────────────────────────
cron
.command('stats')
.description('Get cron job execution statistics')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const result = await client.agentCronJob.getStats.query();
const stats = (result as any).data;
if (options.json) {
console.log(JSON.stringify(stats, null, 2));
return;
}
if (!stats) {
console.log('No statistics available.');
return;
}
for (const [key, value] of Object.entries(stats as Record<string, any>)) {
console.log(`${pc.bold(key + ':')} ${value}`);
}
});
}

View File

@@ -3,6 +3,7 @@ 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 registerMessageCommand(program: Command) {
const message = program.command('message').description('Manage messages');
@@ -159,6 +160,198 @@ export function registerMessageCommand(program: Command) {
console.log(`Messages: ${pc.bold(String(count))}`);
});
// ── create ────────────────────────────────────────────
message
.command('create')
.description('Create a message')
.requiredOption('-r, --role <role>', 'Message role (user, assistant, system)')
.requiredOption('-c, --content <content>', 'Message content')
.option('--agent-id <id>', 'Agent ID')
.option('--topic-id <id>', 'Topic ID')
.option('--session-id <id>', 'Session ID')
.option('--json', 'Output JSON')
.action(
async (options: {
agentId?: string;
content: string;
json?: boolean;
role: string;
sessionId?: string;
topicId?: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {
content: options.content,
role: options.role,
};
if (options.agentId) input.agentId = options.agentId;
if (options.topicId) input.topicId = options.topicId;
if (options.sessionId) input.sessionId = options.sessionId;
const result = await client.message.createMessage.mutate(input as any);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const r = result as any;
console.log(`${pc.green('✓')} Created message ${pc.bold(r.id || '')}`);
},
);
// ── edit ────────────────────────────────────────────
message
.command('edit <id>')
.description('Update a message')
.option('-c, --content <content>', 'New content')
.option('--role <role>', 'New role')
.action(async (id: string, options: { content?: string; role?: string }) => {
const value: Record<string, any> = {};
if (options.content) value.content = options.content;
if (options.role) value.role = options.role;
if (Object.keys(value).length === 0) {
log.error('No changes specified. Use --content or --role.');
process.exit(1);
}
const client = await getTrpcClient();
await client.message.update.mutate({ id, value } as any);
console.log(`${pc.green('✓')} Updated message ${pc.bold(id)}`);
});
// ── add-files ───────────────────────────────────────
message
.command('add-files <id>')
.description('Add files to a message')
.requiredOption('--file-ids <ids>', 'Comma-separated file IDs')
.action(async (id: string, options: { fileIds: string }) => {
const fileIds = options.fileIds.split(',').map((s) => s.trim());
const client = await getTrpcClient();
await client.message.addFilesToMessage.mutate({ fileIds, id } as any);
console.log(`${pc.green('✓')} Added ${fileIds.length} file(s) to message ${pc.bold(id)}`);
});
// ── word-count ──────────────────────────────────────
message
.command('word-count')
.description('Count total words in 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.countWords.query(input as any);
if (options.json) {
console.log(JSON.stringify({ wordCount: count }));
return;
}
console.log(`Word count: ${pc.bold(String(count))}`);
});
// ── rank-models ─────────────────────────────────────
message
.command('rank-models')
.description('Rank models by message usage')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const result = await client.message.rankModels.query();
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const items = Array.isArray(result) ? result : [];
if (items.length === 0) {
console.log('No model usage data.');
return;
}
const rows = items.map((m: any) => [m.id || m.model || '', String(m.count || 0)]);
printTable(rows, ['MODEL', 'COUNT']);
});
// ── delete-by-assistant ─────────────────────────────
message
.command('delete-by-assistant')
.description('Delete messages by assistant context')
.option('--agent-id <id>', 'Agent ID')
.option('--session-id <id>', 'Session ID')
.option('--topic-id <id>', 'Topic ID')
.option('--yes', 'Skip confirmation prompt')
.action(
async (options: {
agentId?: string;
sessionId?: string;
topicId?: string;
yes?: boolean;
}) => {
if (!options.agentId && !options.sessionId) {
log.error('Specify at least --agent-id or --session-id.');
process.exit(1);
}
if (!options.yes) {
const confirmed = await confirm('Are you sure you want to delete messages by assistant?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
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.topicId) input.topicId = options.topicId;
await client.message.removeMessagesByAssistant.mutate(input as any);
console.log(`${pc.green('✓')} Deleted messages by assistant`);
},
);
// ── delete-by-group ─────────────────────────────────
message
.command('delete-by-group <groupId>')
.description('Delete messages by group')
.option('--topic-id <id>', 'Topic ID')
.option('--yes', 'Skip confirmation prompt')
.action(async (groupId: string, options: { topicId?: string; yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm('Are you sure you want to delete messages by group?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
const input: Record<string, any> = { groupId };
if (options.topicId) input.topicId = options.topicId;
await client.message.removeMessagesByGroup.mutate(input as any);
console.log(`${pc.green('✓')} Deleted messages for group ${pc.bold(groupId)}`);
});
// ── heatmap ───────────────────────────────────────────
message

View File

@@ -0,0 +1,139 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerSessionGroupCommand } from './session-group';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
sessionGroup: {
createSessionGroup: { mutate: vi.fn() },
getSessionGroup: { query: vi.fn() },
removeSessionGroup: { mutate: vi.fn() },
updateSessionGroup: { mutate: vi.fn() },
updateSessionGroupOrder: { 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('session-group 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.sessionGroup)) {
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();
registerSessionGroupCommand(program);
return program;
}
describe('list', () => {
it('should list session groups', async () => {
mockTrpcClient.sessionGroup.getSessionGroup.query.mockResolvedValue([
{ id: 'sg1', name: 'Group 1', sort: 0 },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'session-group', 'list']);
expect(mockTrpcClient.sessionGroup.getSessionGroup.query).toHaveBeenCalled();
});
it('should show empty message when no groups', async () => {
mockTrpcClient.sessionGroup.getSessionGroup.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'session-group', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No session groups found.');
});
});
describe('create', () => {
it('should create a session group', async () => {
mockTrpcClient.sessionGroup.createSessionGroup.mutate.mockResolvedValue('sg1');
const program = createProgram();
await program.parseAsync(['node', 'test', 'session-group', 'create', '-n', 'My Group']);
expect(mockTrpcClient.sessionGroup.createSessionGroup.mutate).toHaveBeenCalledWith(
expect.objectContaining({ name: 'My Group' }),
);
});
});
describe('edit', () => {
it('should update a session group', async () => {
mockTrpcClient.sessionGroup.updateSessionGroup.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'session-group', 'edit', 'sg1', '-n', 'New Name']);
expect(mockTrpcClient.sessionGroup.updateSessionGroup.mutate).toHaveBeenCalledWith({
id: 'sg1',
value: expect.objectContaining({ name: 'New Name' }),
});
});
it('should error when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'session-group', 'edit', 'sg1']);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
it('should delete a session group', async () => {
mockTrpcClient.sessionGroup.removeSessionGroup.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'session-group', 'delete', 'sg1', '--yes']);
expect(mockTrpcClient.sessionGroup.removeSessionGroup.mutate).toHaveBeenCalledWith({
id: 'sg1',
});
});
});
describe('sort', () => {
it('should update sort order', async () => {
mockTrpcClient.sessionGroup.updateSessionGroupOrder.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'session-group', 'sort', '--map', 'sg1:0,sg2:1']);
expect(mockTrpcClient.sessionGroup.updateSessionGroupOrder.mutate).toHaveBeenCalledWith({
sortMap: [
{ id: 'sg1', sort: 0 },
{ id: 'sg2', sort: 1 },
],
});
});
});
});

View File

@@ -0,0 +1,120 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable } from '../utils/format';
import { log } from '../utils/logger';
export function registerSessionGroupCommand(program: Command) {
const sessionGroup = program.command('session-group').description('Manage agent session groups');
// ── list ──────────────────────────────────────────────
sessionGroup
.command('list')
.description('List all session groups')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const groups = await client.sessionGroup.getSessionGroup.query();
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(groups, fields);
return;
}
if (!groups || (groups as any[]).length === 0) {
console.log('No session groups found.');
return;
}
const rows = (groups as any[]).map((g: any) => [
g.id || '',
g.name || '',
String(g.sort ?? ''),
]);
printTable(rows, ['ID', 'NAME', 'SORT']);
});
// ── create ────────────────────────────────────────────
sessionGroup
.command('create')
.description('Create a session group')
.requiredOption('-n, --name <name>', 'Group name')
.option('-s, --sort <n>', 'Sort order')
.action(async (options: { name: string; sort?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { name: options.name };
if (options.sort) input.sort = Number.parseInt(options.sort, 10);
const id = await client.sessionGroup.createSessionGroup.mutate(input as any);
console.log(`${pc.green('✓')} Created session group ${pc.bold(String(id || ''))}`);
});
// ── edit ───────────────────────────────────────────────
sessionGroup
.command('edit <id>')
.description('Update a session group')
.option('-n, --name <name>', 'Group name')
.option('-s, --sort <n>', 'Sort order')
.action(async (id: string, options: { name?: string; sort?: string }) => {
const value: Record<string, any> = {};
if (options.name) value.name = options.name;
if (options.sort) value.sort = Number.parseInt(options.sort, 10);
if (Object.keys(value).length === 0) {
log.error('No changes specified. Use --name or --sort.');
process.exit(1);
}
const client = await getTrpcClient();
await client.sessionGroup.updateSessionGroup.mutate({ id, value } as any);
console.log(`${pc.green('✓')} Updated session group ${pc.bold(id)}`);
});
// ── delete ────────────────────────────────────────────
sessionGroup
.command('delete <id>')
.description('Delete a session group')
.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 session group?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.sessionGroup.removeSessionGroup.mutate({ id });
console.log(`${pc.green('✓')} Deleted session group ${pc.bold(id)}`);
});
// ── sort ──────────────────────────────────────────────
sessionGroup
.command('sort')
.description('Update session group sort order')
.requiredOption('--map <entries>', 'Comma-separated id:sort pairs (e.g. "id1:0,id2:1,id3:2")')
.action(async (options: { map: string }) => {
const sortMap = options.map.split(',').map((entry) => {
const [id, sort] = entry.trim().split(':');
if (!id || sort === undefined) {
log.error(`Invalid sort entry: "${entry}". Use format "id:sort".`);
process.exit(1);
}
return { id, sort: Number.parseInt(sort, 10) };
});
const client = await getTrpcClient();
await client.sessionGroup.updateSessionGroupOrder.mutate({ sortMap });
console.log(`${pc.green('✓')} Updated sort order for ${sortMap.length} group(s)`);
});
}

View File

@@ -157,6 +157,111 @@ export function registerTopicCommand(program: Command) {
console.log(`${pc.green('✓')} Deleted ${ids.length} topic(s)`);
});
// ── clone ───────────────────────────────────────────
topic
.command('clone <id>')
.description('Clone a topic')
.option('-t, --title <title>', 'New title for the cloned topic')
.action(async (id: string, options: { title?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { id };
if (options.title) input.newTitle = options.title;
const newId = await client.topic.cloneTopic.mutate(input as any);
console.log(`${pc.green('✓')} Cloned topic → ${pc.bold(String(newId || ''))}`);
});
// ── share ──────────────────────────────────────────
topic
.command('share <id>')
.description('Enable sharing for a topic')
.option('--visibility <v>', 'Visibility: private or link', 'link')
.action(async (id: string, options: { visibility?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { topicId: id };
if (options.visibility) input.visibility = options.visibility;
const result = await client.topic.enableSharing.mutate(input as any);
const r = result as any;
console.log(`${pc.green('✓')} Sharing enabled for topic ${pc.bold(id)}`);
if (r.shareId) {
console.log(` Share ID: ${pc.bold(r.shareId)}`);
}
});
// ── unshare ────────────────────────────────────────
topic
.command('unshare <id>')
.description('Disable sharing for a topic')
.action(async (id: string) => {
const client = await getTrpcClient();
await client.topic.disableSharing.mutate({ topicId: id });
console.log(`${pc.green('✓')} Sharing disabled for topic ${pc.bold(id)}`);
});
// ── share-info ─────────────────────────────────────
topic
.command('share-info <id>')
.description('View sharing info for a topic')
.option('--json', 'Output JSON')
.action(async (id: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const info = await client.topic.getShareInfo.query({ topicId: id });
if (options.json) {
console.log(JSON.stringify(info, null, 2));
return;
}
if (!info) {
console.log('Sharing not enabled for this topic.');
return;
}
const i = info as any;
console.log(`${pc.bold('Topic ID:')} ${id}`);
if (i.shareId) console.log(`${pc.bold('Share ID:')} ${i.shareId}`);
if (i.visibility) console.log(`${pc.bold('Visibility:')} ${i.visibility}`);
if (i.createdAt) console.log(`${pc.bold('Created:')} ${i.createdAt}`);
});
// ── import ─────────────────────────────────────────
topic
.command('import')
.description('Import a topic')
.requiredOption('--agent-id <id>', 'Agent ID')
.requiredOption('--data <json>', 'Topic data as JSON string')
.option('--group-id <id>', 'Group ID')
.option('--json', 'Output JSON')
.action(
async (options: { agentId: string; data: string; groupId?: string; json?: boolean }) => {
const client = await getTrpcClient();
const input: Record<string, any> = {
agentId: options.agentId,
data: options.data,
};
if (options.groupId) input.groupId = options.groupId;
const result = await client.topic.importTopic.mutate(input as any);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(`${pc.green('✓')} Topic imported successfully`);
},
);
// ── recent ────────────────────────────────────────────
topic

View File

@@ -3,9 +3,11 @@ import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
import { registerAgentGroupCommand } from './commands/agent-group';
import { registerBotCommand } from './commands/bot';
import { registerConfigCommand } from './commands/config';
import { registerConnectCommand } from './commands/connect';
import { registerCronCommand } from './commands/cron';
import { registerDeviceCommand } from './commands/device';
import { registerDocCommand } from './commands/doc';
import { registerEvalCommand } from './commands/eval';
@@ -20,6 +22,7 @@ import { registerModelCommand } from './commands/model';
import { registerPluginCommand } from './commands/plugin';
import { registerProviderCommand } from './commands/provider';
import { registerSearchCommand } from './commands/search';
import { registerSessionGroupCommand } from './commands/session-group';
import { registerSkillCommand } from './commands/skill';
import { registerStatusCommand } from './commands/status';
import { registerTopicCommand } from './commands/topic';
@@ -44,10 +47,13 @@ registerSearchCommand(program);
registerKbCommand(program);
registerMemoryCommand(program);
registerAgentCommand(program);
registerAgentGroupCommand(program);
registerBotCommand(program);
registerCronCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
registerSkillCommand(program);
registerSessionGroupCommand(program);
registerTopicCommand(program);
registerMessageCommand(program);
registerModelCommand(program);