mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
@@ -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",
|
||||
|
||||
178
apps/cli/src/commands/agent-group.test.ts
Normal file
178
apps/cli/src/commands/agent-group.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
215
apps/cli/src/commands/agent-group.ts
Normal file
215
apps/cli/src/commands/agent-group.ts
Normal 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)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
172
apps/cli/src/commands/cron.test.ts
Normal file
172
apps/cli/src/commands/cron.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
271
apps/cli/src/commands/cron.ts
Normal file
271
apps/cli/src/commands/cron.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
139
apps/cli/src/commands/session-group.test.ts
Normal file
139
apps/cli/src/commands/session-group.test.ts
Normal 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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
120
apps/cli/src/commands/session-group.ts
Normal file
120
apps/cli/src/commands/session-group.ts
Normal 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)`);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user