feat(cli): add CRUD helper, resource definitions, and help command

Resource-first CLI: `nc groups list`, `nc wirings get <id>`, etc.
Seven resources defined (groups, messaging-groups, wirings, users,
roles, members, sessions) with full column documentation that serves
as the single source of truth for help output and arg validation.

- CRUD helper auto-registers list/get/create/update/delete from
  declarative resource definitions with generic SQL
- Custom operations for composite-PK resources (roles grant/revoke,
  members add/remove)
- Access model: open (reads) / approval (writes) / hidden
- `nc help` lists resources; `nc <resource> help` shows fields
- Positional target IDs: `nc groups get <id>`
- Removed unused priority column from wirings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-05-06 00:33:10 +03:00
parent 5e2bf1cb54
commit 6865811147
16 changed files with 810 additions and 46 deletions

106
src/cli/commands/help.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Built-in help command. Introspects the resource and command registries.
*
* nc help — list all resources and commands
* nc groups help — show group resource details (verbs, columns, enums)
*/
import { getResource, getResources } from '../crud.js';
import { listCommands, register } from '../registry.js';
register({
name: 'help',
description: 'List available resources and commands.',
access: 'open',
parseArgs: () => ({}),
handler: async () => {
const resources = getResources();
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
const lines: string[] = [];
if (resources.length > 0) {
lines.push('Resources:');
for (const r of resources) {
const ops: string[] = [];
if (r.operations.list) ops.push('list');
if (r.operations.get) ops.push('get');
if (r.operations.create) ops.push('create');
if (r.operations.update) ops.push('update');
if (r.operations.delete) ops.push('delete');
if (r.customOperations) ops.push(...Object.keys(r.customOperations));
lines.push(` ${r.plural.padEnd(20)} ${r.description}`);
lines.push(` ${''.padEnd(20)} verbs: ${ops.join(', ')}`);
}
}
if (commands.length > 0) {
if (lines.length > 0) lines.push('');
lines.push('Commands:');
for (const c of commands) {
lines.push(` ${c.name.padEnd(20)} ${c.description}`);
}
}
lines.push('');
lines.push('Run `nc <resource> help` for detailed field information.');
return lines.join('\n');
},
});
// Register per-resource help commands. These are registered dynamically
// after the resources barrel has been imported.
// We use a lazy approach: register a catch-all pattern isn't possible with
// the flat registry, so we register `<plural>-help` for each resource
// in a post-import hook.
export function registerResourceHelpCommands(): void {
for (const res of getResources()) {
// Skip if already registered (e.g. from a previous call)
try {
register({
name: `${res.plural}-help`,
description: `Show ${res.name} resource details.`,
access: 'open',
resource: res.plural,
parseArgs: () => ({}),
handler: async () => {
const lines: string[] = [];
lines.push(`${res.plural}: ${res.description}`);
lines.push('');
// Verbs
const verbs: string[] = [];
if (res.operations.list) verbs.push(`list [open]`);
if (res.operations.get) verbs.push(`get <id> [open]`);
if (res.operations.create) verbs.push(`create [approval]`);
if (res.operations.update) verbs.push(`update <id> [approval]`);
if (res.operations.delete) verbs.push(`delete <id> [approval]`);
if (res.customOperations) {
for (const [verb, op] of Object.entries(res.customOperations)) {
verbs.push(`${verb} [${op.access}] — ${op.description}`);
}
}
lines.push('Verbs:');
for (const v of verbs) lines.push(` ${v}`);
lines.push('');
// Columns
lines.push('Fields:');
for (const col of res.columns) {
const tags: string[] = [];
if (col.generated) tags.push('auto');
if (col.required) tags.push('required');
if (col.updatable) tags.push('updatable');
if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`);
if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`);
const flag = `--${col.name.replace(/_/g, '-')}`;
const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : '';
lines.push(` ${flag.padEnd(28)} ${col.description}${tagStr}`);
}
return lines.join('\n');
},
});
} catch {
// Already registered — skip
}
}
}

View File

@@ -1,4 +1,10 @@
// Side-effect imports — each command file calls register() at top level.
// Imported by src/index.ts on host startup so the registry is populated
// before the CLI server accepts connections.
import './list-groups.js';
/**
* Command barrel — populates the registry before the CLI server starts.
*
* Resource definitions register their CRUD commands on import.
* Help commands are registered after resources are loaded.
*/
import '../resources/index.js';
import { registerResourceHelpCommands } from './help.js';
registerResourceHelpCommands();

View File

@@ -1,17 +0,0 @@
import { getAllAgentGroups } from '../../db/agent-groups.js';
import { register } from '../registry.js';
register({
name: 'list-groups',
description: 'List all agent groups.',
riskClass: 'safe',
parseArgs: () => ({}),
handler: async () =>
getAllAgentGroups().map((g) => ({
id: g.id,
name: g.name,
folder: g.folder,
provider: g.agent_provider ?? 'claude',
created_at: g.created_at,
})),
});