Merge branch 'main' into feat/core-team-pr-label

This commit is contained in:
Gabi Simons
2026-07-08 14:35:23 +03:00
committed by GitHub
13 changed files with 946 additions and 50 deletions
+6 -1
View File
@@ -22,7 +22,9 @@ type RequestFrame = {
};
type ResponseFrame =
| { id: string; ok: true; data: unknown }
// `human` mirrors src/cli/frame.ts: an optional server-rendered string we
// print verbatim instead of running our own (drift-prone) formatter.
| { id: string; ok: true; data: unknown; human?: string }
| { id: string; ok: false; error: { code: string; message: string } };
// ---------------------------------------------------------------------------
@@ -244,6 +246,9 @@ if (!resp) {
if (json) {
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
} else if (resp.ok && resp.human !== undefined) {
// Server-rendered view — print verbatim.
process.stdout.write(resp.human + '\n');
} else {
const output = formatHuman(resp);
if (!resp.ok) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.39",
"version": "2.1.40",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="209k tokens, 104% of context window">
<title>209k tokens, 104% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="213k tokens, 106% of context window">
<title>213k tokens, 106% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">209k</text>
<text x="71" y="14">209k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">213k</text>
<text x="71" y="14">213k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+8 -2
View File
@@ -43,8 +43,14 @@ async function main(): Promise<void> {
process.exit(2);
}
process.stdout.write(formatResponse(res, json ? 'json' : 'human'));
process.exit(res.ok ? 0 : 1);
const output =
!json && res.ok && res.human !== undefined
? res.human + '\n' // server-rendered view — print verbatim
: formatResponse(res, json ? 'json' : 'human');
// Exit only after stdout drains: process.exit() discards buffered pipe
// writes, silently truncating any response past the 64KB pipe buffer
// (bit `ncl sessions list --json` at scale).
process.stdout.write(output, () => process.exit(res.ok ? 0 : 1));
}
function pickTransport(): Transport {
+32 -14
View File
@@ -5,11 +5,10 @@
* ncl groups help — show group resource details (verbs, columns, enums)
*/
import { getContainerConfig } from '../../db/container-configs.js';
import { getResource, getResources } from '../crud.js';
import { getResources } from '../crud.js';
import { renderVerbHelp, summaryLine } from '../help-render.js';
import type { CallerContext } from '../frame.js';
import { listCommands, register } from '../registry.js';
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
import { GROUP_SCOPE_RESOURCES, listCommands, register } from '../registry.js';
function getCliScope(ctx: CallerContext): string | undefined {
if (ctx.caller !== 'agent') return undefined;
@@ -76,13 +75,28 @@ export function registerResourceHelpCommands(): void {
try {
register({
name: `${res.plural}-help`,
description: `Show ${res.name} resource details.`,
description: `Show ${res.name} resource details; \`${res.plural} help <verb>\` for one verb in depth.`,
access: 'open',
resource: res.plural,
parseArgs: () => ({}),
handler: async (_args, ctx) => {
parseArgs: (raw) => raw,
handler: async (args, ctx) => {
const cliScope = getCliScope(ctx);
const lines: string[] = [];
// `ncl <resource> help <verb>` arrives via the dispatcher's
// longest-prefix fallback (`groups-help-create` → `groups-help` +
// id=`create`) and renders that verb's deep help — full description,
// flags, examples. No new routing. Group-scope auto-fill also puts
// the caller's agent group ID into `id` on groups/destinations —
// that's not a verb request, so ignore it.
const autoFilled = ctx.caller === 'agent' && args.id === ctx.agentGroupId;
const verbArg = !autoFilled && typeof args.id === 'string' ? args.id : null;
if (verbArg) {
const deep = renderVerbHelp(res, verbArg);
if (!deep) throw new Error(`no verb "${verbArg}" on ${res.plural} — run \`ncl ${res.plural} help\``);
return deep;
}
lines.push(`${res.plural}: ${res.description}`);
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
@@ -92,23 +106,27 @@ export function registerResourceHelpCommands(): void {
lines.push('');
// Verbs
// Verbs — one summary line each; deep help is a verb away. Only the
// exceptional access levels are tagged: `open` is the unmarked default.
const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
const idHint = idAutoFilled ? '' : ' <id>';
const tag = (access: string | undefined) => (!access || access === 'open' ? '' : ` [${access}]`);
const verbs: string[] = [];
if (res.operations.list) verbs.push(`list [open]`);
if (res.operations.get) verbs.push(`get${idHint} [open]`);
if (res.operations.create) verbs.push(`create [approval]`);
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
if (res.operations.list) verbs.push(`list${tag(res.operations.list)}`);
if (res.operations.get) verbs.push(`get${idHint}${tag(res.operations.get)}`);
if (res.operations.create) verbs.push(`create${tag(res.operations.create)}`);
if (res.operations.update) verbs.push(`update${idHint}${tag(res.operations.update)}`);
if (res.operations.delete) verbs.push(`delete${idHint}${tag(res.operations.delete)}`);
if (res.customOperations) {
for (const [verb, op] of Object.entries(res.customOperations)) {
verbs.push(`${verb} [${op.access}]${op.description}`);
verbs.push(`${verb}${tag(op.access)}${summaryLine(op.description)}`);
}
}
lines.push('Verbs:');
for (const v of verbs) lines.push(` ${v}`);
lines.push('');
lines.push(`Run \`ncl ${res.plural} help <verb>\` (or add --help to any command) for flags and examples.`);
lines.push('');
// Columns
const autoFilledFields =
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('../db/connection.js', () => ({ getDb: vi.fn() }));
vi.mock('../db/container-configs.js', () => ({
getContainerConfig: vi.fn(() => ({ cli_scope: 'group' })),
}));
import { registerResource, validateArgs } from './crud.js';
import { registerResourceHelpCommands } from './commands/help.js';
import { lookup } from './registry.js';
import type { CallerContext } from './frame.js';
// --- validateArgs unit ---
describe('validateArgs', () => {
const defs = [
{ name: 'target', type: 'string' as const, description: 'Target.', required: true },
{ name: 'count', type: 'number' as const, description: 'Count.', default: 1 },
{ name: 'force', type: 'boolean' as const, description: 'Force.' },
{ name: 'meta', type: 'json' as const, description: 'Meta.' },
{ name: 'size', type: 'string' as const, description: 'Size.', enum: ['s', 'm', 'l'] },
];
it('coerces types per declaration', () => {
const out = validateArgs(defs, { target: 'x', count: '5', force: 'true', meta: '{"a":1}' });
expect(out).toMatchObject({ target: 'x', count: 5, force: true, meta: { a: 1 } });
});
it('applies defaults for absent optional flags', () => {
expect(validateArgs(defs, { target: 'x' }).count).toBe(1);
});
it('rejects a missing required flag', () => {
expect(() => validateArgs(defs, {})).toThrow('--target is required');
});
it('rejects unknown flags', () => {
expect(() => validateArgs(defs, { target: 'x', bogus: '1' })).toThrow('unknown flag --bogus');
});
it('tolerates dispatch-injected keys without declaration', () => {
const out = validateArgs(defs, { target: 'x', id: 'ag-1', agent_group_id: 'ag-1', group: 'ag-1' });
expect(out.id).toBe('ag-1');
});
it('rejects enum violations', () => {
expect(() => validateArgs(defs, { target: 'x', size: 'xl' })).toThrow('--size must be one of: s, m, l');
});
it('rejects non-numeric values for number flags', () => {
expect(() => validateArgs(defs, { target: 'x', count: 'many' })).toThrow('--count must be a number');
});
it('rejects a value-less flag on a non-boolean (client sends true)', () => {
expect(() => validateArgs(defs, { target: true })).toThrow('--target requires a value');
});
it('accepts a value-less boolean flag', () => {
expect(validateArgs(defs, { target: 'x', force: true }).force).toBe(true);
});
it('rejects invalid JSON', () => {
expect(() => validateArgs(defs, { target: 'x', meta: '{nope' })).toThrow('--meta must be valid JSON');
});
});
// --- registerResource wiring: strict where declared, lenient otherwise ---
registerResource({
name: 'widget',
plural: 'widgets',
table: 'widgets',
description: 'Test widgets.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{ name: 'name', type: 'string', description: 'Display name.', required: true, updatable: true },
],
operations: {},
customOperations: {
ping: {
access: 'open',
description: 'Ping a widget.',
args: [{ name: 'target', type: 'string', description: 'Where to ping.', required: true }],
examples: ['ncl widgets ping --target prod'],
handler: async (args) => ({ echo: args }),
},
legacy: {
access: 'open',
description: 'Legacy op without declared args.',
handler: async (args) => ({ echo: args }),
},
},
});
registerResourceHelpCommands();
describe('strict validation wiring (declared args)', () => {
const parse = lookup('widgets-ping')!.parseArgs;
it('passes and coerces valid args', () => {
expect(parse({ target: 'prod' })).toMatchObject({ target: 'prod' });
});
it('failure carries the verb usage block (error + fix in one round-trip)', () => {
let message = '';
try {
parse({});
} catch (e) {
message = (e as Error).message;
}
expect(message).toContain('--target is required');
expect(message).toContain('ncl widgets ping'); // usage line
expect(message).toContain('Flags:');
expect(message).toContain('Examples:');
});
it('rejects unknown flags with the usage block', () => {
expect(() => parse({ target: 'prod', bogus: '1' })).toThrow(/unknown flag --bogus[\s\S]*Flags:/);
});
it('normalizes dashed flags before validating', () => {
// --target arrives as raw key "target"; a dashed alias like "tar-get" would
// normalize to underscores — prove normalize runs before validate.
expect(() => parse({ 'bogus-flag': '1', target: 'x' })).toThrow('unknown flag --bogus-flag');
});
});
describe('lenient ops (no declared args) keep legacy behavior', () => {
it('passes stray flags through untouched', () => {
const parse = lookup('widgets-legacy')!.parseArgs;
expect(parse({ anything: 'goes', 'dash-key': '1' })).toMatchObject({ anything: 'goes', dash_key: '1' });
});
});
// --- resource help: deep verb view + group-scope auto-fill guard ---
describe('resource help command', () => {
const helpCmd = lookup('widgets-help')!;
const host: CallerContext = { caller: 'host' };
const agent: CallerContext = {
caller: 'agent',
sessionId: 'sess-1',
agentGroupId: 'ag-1',
messagingGroupId: 'mg-1',
};
it('renders the resource overview with verb summaries', async () => {
const out = (await helpCmd.handler(helpCmd.parseArgs({}), host)) as string;
expect(out).toContain('widgets: Test widgets.');
expect(out).toContain('ping — Ping a widget.');
expect(out).toContain('help <verb>');
});
it('renders deep help for `help <verb>` (id from prefix fallback)', async () => {
const out = (await helpCmd.handler(helpCmd.parseArgs({ id: 'ping' }), host)) as string;
expect(out).toContain('ncl widgets ping');
expect(out).toContain('--target');
expect(out).toContain('Examples:');
});
it('errors on an unknown verb', async () => {
await expect(helpCmd.handler(helpCmd.parseArgs({ id: 'bogus' }), host)).rejects.toThrow(
'no verb "bogus" on widgets',
);
});
it('treats an auto-filled agent group id as no verb (scoped agent, plain help)', async () => {
// dispatch auto-fills id=ctx.agentGroupId on groups/destinations; the
// handler must show the overview, not "no verb <uuid>".
const out = (await helpCmd.handler(helpCmd.parseArgs({ id: 'ag-1' }), agent)) as string;
expect(out).toContain('widgets: Test widgets.');
});
});
+112 -3
View File
@@ -9,6 +9,7 @@
import { randomUUID } from 'crypto';
import { getDb } from '../db/connection.js';
import { renderVerbHelp } from './help-render.js';
import { register } from './registry.js';
import type { Access } from './registry.js';
import type { CallerContext } from './frame.js';
@@ -37,9 +38,21 @@ export interface ColumnDef {
export interface CustomOperation {
access: Access;
/** First line = one-line summary (resource help). Full text renders in the
* per-verb deep help (`ncl <resource> help <verb>` / `--help`). */
description: string;
/**
* Declaring args opts this verb into strict validation: required/enum/type
* checks plus unknown-flag rejection, with the verb's generated usage block
* appended to every failure. Omit to keep the legacy lenient behavior
* (handler validates by hand, stray flags ignored).
*/
args?: ColumnDef[];
/** Ready-to-paste invocations, rendered under EXAMPLES in deep help. */
examples?: string[];
handler: (args: Record<string, unknown>, ctx: CallerContext) => Promise<unknown>;
/** Presentational renderer for human mode — see CommandDef.formatHuman. */
formatHuman?: (data: unknown) => string;
}
export interface ResourceDef {
@@ -110,8 +123,11 @@ function genericList(def: ResourceDef) {
}
const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : '';
params.push(limit);
// Newest first: without an ORDER BY the LIMIT silently hides the most
// recently inserted rows once a table outgrows it (bit `sessions list`
// past 200 sessions — a just-created session was invisible).
return getDb()
.prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`)
.prepare(`SELECT ${cols} FROM ${def.table}${where} ORDER BY rowid DESC LIMIT ?`)
.all(...params);
};
}
@@ -222,6 +238,85 @@ function normalizeArgs(raw: Record<string, unknown>): Record<string, unknown> {
return out;
}
// ---------------------------------------------------------------------------
// Strict arg validation (opt-in via CustomOperation.args)
// ---------------------------------------------------------------------------
/**
* Keys the dispatcher may inject into `req.args` before parseArgs runs
* (group-scope auto-fill). Strict validation must tolerate them even when a
* verb doesn't declare them, or every scoped agent call breaks.
*/
const DISPATCH_INJECTED_KEYS = ['id', 'agent_group_id', 'group'] as const;
/**
* Validate `args` (already underscore-normalized) against a ColumnDef list:
* unknown-flag rejection, required, enum, and type coercion per the declared
* type. Returns a coerced copy; throws with a focused message on the first
* problem. Works from any ColumnDef list so generic CRUD resources can opt
* into the same strictness later without a second validator.
*/
export function validateArgs(
defs: ColumnDef[],
args: Record<string, unknown>,
opts: { allowExtra?: readonly string[] } = {},
): Record<string, unknown> {
const declared = new Map(defs.map((d) => [d.name, d]));
const allowed = new Set<string>([...declared.keys(), ...(opts.allowExtra ?? DISPATCH_INJECTED_KEYS)]);
for (const key of Object.keys(args)) {
if (!allowed.has(key)) {
throw new Error(`unknown flag --${key.replace(/_/g, '-')}`);
}
}
const out: Record<string, unknown> = { ...args };
for (const def of defs) {
const flag = `--${def.name.replace(/_/g, '-')}`;
const v = args[def.name];
if (v === undefined) {
if (def.required) throw new Error(`${flag} is required`);
if (def.default !== undefined) out[def.name] = def.default;
continue;
}
// The client parses a value-less `--flag` as boolean true.
if (v === true && def.type !== 'boolean') {
throw new Error(`${flag} requires a value`);
}
switch (def.type) {
case 'number': {
const n = Number(v);
if (Number.isNaN(n)) throw new Error(`${flag} must be a number, got "${v}"`);
out[def.name] = n;
break;
}
case 'boolean': {
if (v === true || v === 'true' || v === '1') out[def.name] = true;
else if (v === false || v === 'false' || v === '0') out[def.name] = false;
else throw new Error(`${flag} must be true or false, got "${v}"`);
break;
}
case 'json': {
if (typeof v === 'string') {
try {
out[def.name] = JSON.parse(v);
} catch {
throw new Error(`${flag} must be valid JSON`);
}
}
break;
}
case 'string':
out[def.name] = String(v);
break;
}
if (def.enum && !def.enum.includes(String(out[def.name]))) {
throw new Error(`${flag} must be one of: ${def.enum.join(', ')}`);
}
}
return out;
}
// ---------------------------------------------------------------------------
// registerResource
// ---------------------------------------------------------------------------
@@ -286,16 +381,30 @@ export function registerResource(def: ResourceDef): void {
});
}
// Custom operations
// Custom operations. Declaring `args` opts the verb into strict validation;
// every failure carries the verb's usage block so a caller (human or agent)
// can fix the invocation without a second help round-trip.
if (def.customOperations) {
for (const [verb, op] of Object.entries(def.customOperations)) {
const declared = op.args;
register({
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
description: op.description,
access: op.access,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
parseArgs: declared
? (raw) => {
try {
return validateArgs(declared, normalizeArgs(raw));
} catch (e) {
const usage = renderVerbHelp(def, verb);
const msg = e instanceof Error ? e.message : String(e);
throw new Error(usage ? `${msg}\n\n${usage}` : msg);
}
}
: (raw) => normalizeArgs(raw),
handler: async (args, ctx) => op.handler(args as Record<string, unknown>, ctx),
formatHuman: op.formatHuman,
});
}
}
+260 -7
View File
@@ -174,6 +174,17 @@ register({
handler: async () => ({ agent_group_id: 'g1', model: 'opus' }),
});
// A dash-joined command whose custom-operation key contains spaces
// ('config update') — used by the --help space/dash bridging test.
register({
name: 'groups-config-update',
description: 'bare registry description (should not be the help answer)',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
// The real `sessions-get` name — triggers the pre-handler ownership check.
register({
name: 'sessions-get',
@@ -595,18 +606,260 @@ describe('CLI scope enforcement', () => {
});
});
// --- Dash-joined positional id resolution (generated ids contain dashes) ---
// Multi-segment command, to prove the longest-prefix match (verb itself has dashes).
register({
name: 'groups-cfg-get',
description: 'test multi-segment command',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
describe('dash-joined positional id resolution', () => {
it('resolves `groups-get-<uuid-with-dashes>` to (groups get, id=<uuid>)', async () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
describe('positional dashed-id resolution', () => {
const host = { caller: 'host' as const };
const resp = await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
it('resolves a long dashed id to command + intact id (no shredding)', async () => {
const id = 'task-374f0630-d3e0-4965-81da-fe4bf7a6a442';
const resp = await dispatch({ id: '1', command: `groups-test-${id}`, args: {} }, host);
expect(resp.ok).toBe(true);
if (resp.ok) expect(resp.data).toEqual({ echo: { id } });
});
it('matches the LONGEST command prefix when the verb itself has dashes', async () => {
const resp = await dispatch({ id: '2', command: 'groups-cfg-get-abc-123', args: {} }, host);
expect(resp.ok).toBe(true);
if (resp.ok) expect(resp.data).toEqual({ echo: { id: 'abc-123' } });
});
it('leaves a registered no-id command alone', async () => {
const resp = await dispatch({ id: '3', command: 'groups-test', args: {} }, host);
expect(resp.ok).toBe(true);
if (resp.ok) expect(resp.data).toEqual({ echo: {} });
});
it('does not override an explicit --id', async () => {
const resp = await dispatch({ id: '4', command: 'groups-test-tail', args: { id: 'explicit' } }, host);
expect(resp.ok).toBe(true);
if (resp.ok) expect(resp.data).toEqual({ echo: { id: 'explicit' } });
});
});
// --- `--help` interception: answer with generated help, execute nothing ---
describe('--help interception', () => {
it('returns command help instead of executing (open command)', async () => {
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { help: true } }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBe(uuid);
// No deep resource def for 'test' → falls back to the description.
expect(resp.data).toBe('test command (non-group resource)');
}
});
it('carries the help text in `human` so clients print it verbatim', async () => {
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { help: true } }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(typeof resp.data).toBe('string');
expect(resp.human).toBe(resp.data);
}
});
it('never mints an approval card for --help on an approval-gated command', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: { help: true } }, agentCtx());
expect(resp.ok).toBe(true);
expect(approvalState.requestApproval).not.toHaveBeenCalled();
expect(approvalState.observedContexts).toHaveLength(0); // handler never ran
});
it('renders deep verb help when the resource def is available', async () => {
mockGetResource.mockImplementation((plural: string) =>
plural === 'groups'
? {
name: 'group',
plural: 'groups',
table: 'agent_groups',
description: 'Agent groups.',
idColumn: 'id',
scopeField: 'id',
columns: [],
operations: {},
customOperations: {
test: {
access: 'open',
description: 'Deep test op.',
args: [{ name: 'foo', type: 'string', description: 'A foo.', required: true }],
handler: async () => ({}),
},
},
}
: undefined,
);
const resp = await dispatch({ id: '1', command: 'groups-test', args: { help: true } }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.data).toContain('ncl groups test');
expect(resp.data).toContain('--foo');
expect(resp.data).toContain('(required)');
}
});
it('renders deep verb help for a multi-word custom-operation key (spaces vs dashes)', async () => {
// registerResource stores the op under 'config update' but registers the
// command as 'groups-config-update'; help must bridge the two.
mockGetResource.mockImplementation((plural: string) =>
plural === 'groups'
? {
name: 'group',
plural: 'groups',
table: 'agent_groups',
description: 'Agent groups.',
idColumn: 'id',
scopeField: 'id',
columns: [],
operations: {},
customOperations: {
'config update': {
access: 'open',
description: 'Update container config.',
args: [{ name: 'model', type: 'string', description: 'Model override.' }],
handler: async () => ({}),
},
},
}
: undefined,
);
const resp = await dispatch({ id: '1', command: 'groups-config-update', args: { help: true } }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.data).toContain('ncl groups config update');
expect(resp.data).toContain('--model');
// Not the bare registry description fallback:
expect(resp.data).not.toBe('bare registry description (should not be the help answer)');
}
});
it('still enforces group scope before answering --help', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'wirings-list', args: { help: true } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) expect(resp.error.code).toBe('forbidden');
});
});
// --- Unknown-command errors carry their fix ---
describe('unknown-command errors', () => {
it('lists the resource verbs when the command names a known resource', async () => {
mockGetResource.mockImplementation((plural: string) =>
plural === 'groups'
? {
name: 'group',
plural: 'groups',
table: 'agent_groups',
description: 'Agent groups.',
idColumn: 'id',
columns: [],
operations: { list: 'open', get: 'open' },
customOperations: {
restart: { access: 'approval', description: 'Restart.', handler: async () => ({}) },
},
}
: undefined,
);
const resp = await dispatch({ id: '1', command: 'groups-restrat', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('unknown-command');
expect(resp.error.message).toContain('verbs for groups: list, get, restart');
expect(resp.error.message).toContain('ncl groups help');
}
});
it('suggests the closest command name for near-miss typos', async () => {
const resp = await dispatch({ id: '1', command: 'test-cm', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('unknown-command');
expect(resp.error.message).toContain('did you mean "test-cmd"?');
}
});
it('falls back to a plain pointer when nothing is close', async () => {
const resp = await dispatch({ id: '1', command: 'zzz-qqq-vvv', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.message).toContain('no command "zzz-qqq-vvv"');
expect(resp.error.message).toContain('ncl help');
expect(resp.error.message).not.toContain('did you mean');
}
});
});
// --- formatHuman hook: server-rendered human view on the frame ---
describe('formatHuman hook', () => {
register({
name: 'render-cmd',
description: 'command with a human renderer',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => [{ id: 'x1', status: 'live' }],
formatHuman: (rows) => `TABLE(${(rows as { id: string }[]).map((r) => r.id).join(',')})`,
});
register({
name: 'render-throws',
description: 'command whose renderer throws',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => ({ fine: true }),
formatHuman: () => {
throw new Error('renderer bug');
},
});
it('attaches human alongside data', async () => {
const resp = await dispatch({ id: '1', command: 'render-cmd', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.human).toBe('TABLE(x1)');
expect(resp.data).toEqual([{ id: 'x1', status: 'live' }]); // machine contract intact
}
});
it('a throwing renderer degrades to a plain frame, never an error', async () => {
const resp = await dispatch({ id: '1', command: 'render-throws', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.human).toBeUndefined();
expect(resp.data).toEqual({ fine: true });
}
});
it('commands without a renderer stay human-less', async () => {
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) expect(resp.human).toBeUndefined();
});
});
+116 -16
View File
@@ -12,7 +12,8 @@ import { getSession } from '../db/sessions.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { getResource } from './crud.js';
import { lookup } from './registry.js';
import { listVerbs, renderVerbHelp } from './help-render.js';
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
type DispatchOptions = {
/** True when a command is being replayed after approval. */
@@ -26,21 +27,22 @@ export async function dispatch(
): Promise<ResponseFrame> {
let cmd = lookup(req.command);
// Fallback: if the full command isn't registered, split the dash-joined
// command and treat the longest registered prefix as the command, with the
// re-joined remainder as the target ID. Clients join all positional args
// with dashes (e.g. `ncl groups get abc123` → command "groups-get-abc123"),
// and generated ids (UUIDs, `sess-…`, `appr-…`) themselves contain dashes,
// so trimming a single trailing segment isn't enough — walk prefixes from
// longest to shortest so `groups-get-<uuid-with-dashes>` still resolves to
// "groups-get" + id "<uuid-with-dashes>".
// Fallback: if the full command isn't registered, find the LONGEST registered
// command that is a dash-prefix of req.command; the remainder is the target ID,
// kept intact (dashes and all). This lets clients join all positional args with
// dashes e.g. `ncl groups get abc123` → "groups-get-abc123" → "groups-get" +
// id "abc123", and crucially `ncl tasks cancel task-374f-...-442` →
// "tasks-cancel" + id "task-374f-...-442" (a dashed id is no longer shredded).
// Trimming from the end (longestshortest) means a multi-segment verb like
// "groups-config-add-mcp-server" still matches before any shorter prefix.
if (!cmd) {
const parts = req.command.split('-');
for (let i = parts.length - 1; i > 0; i--) {
const shortened = parts.slice(0, i).join('-');
let shortened = req.command;
let idx: number;
while ((idx = shortened.lastIndexOf('-')) > 0) {
shortened = shortened.slice(0, idx);
const fallback = lookup(shortened);
if (fallback) {
const tail = parts.slice(i).join('-');
const tail = req.command.slice(shortened.length + 1); // full remainder = id, dashes intact
cmd = fallback;
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
break;
@@ -49,7 +51,7 @@ export async function dispatch(
}
if (!cmd) {
return err(req.id, 'unknown-command', `no command "${req.command}"`);
return err(req.id, 'unknown-command', unknownCommandMessage(req.command));
}
// CLI scope enforcement for agent callers
@@ -62,9 +64,8 @@ export async function dispatch(
}
if (cliScope === 'group') {
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
// Only allow whitelisted resources and general commands (no resource, like help)
if (cmd.resource && !allowed.has(cmd.resource)) {
if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) {
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
}
@@ -115,6 +116,17 @@ export async function dispatch(
}
}
// `--help` interception: answer with the command's generated help instead of
// executing. Placed after scope enforcement (a group-scoped agent can't probe
// forbidden resources) and BEFORE approval gating — asking for help on an
// approval-gated verb must never mint an approval card.
if (req.args.help === true) {
// Carry the help text in `human` too, so both clients print it verbatim
// as clean multi-line text instead of a JSON-stringified blob.
const helpText = commandHelp(cmd.name, cmd.resource, cmd.description);
return { id: req.id, ok: true, data: helpText, human: helpText };
}
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
const session = getSession(ctx.sessionId);
if (!session) {
@@ -186,6 +198,17 @@ export async function dispatch(
}
}
// Server-render the human view once, so every transport — host CLI and
// the Bun container client (which can't import host formatters) — prints
// one canonical rendering. Runs after scope filtering; a throwing
// formatter degrades to plain `data`, never fails the response.
if (cmd.formatHuman) {
try {
return { id: req.id, ok: true, data, human: cmd.formatHuman(data) };
} catch {
// fall through to the plain frame
}
}
return { id: req.id, ok: true, data };
} catch (e) {
return err(req.id, 'handler-error', errMsg(e));
@@ -225,6 +248,83 @@ function parseCallerContext(value: unknown): CallerContext | undefined {
return undefined;
}
/** Help text for a resolved command: deep verb help when derivable, else description. */
function commandHelp(name: string, resource: string | undefined, description: string): string {
if (resource && name.startsWith(`${resource}-`)) {
const res = getResource(resource);
const verb = name.slice(resource.length + 1);
const deep = res && renderVerbHelp(res, verb);
if (deep) return deep;
// Custom-operation KEYS may contain spaces ('config update') while command
// names are dash-joined ('groups-config-update'). Resolve by matching keys
// normalized the same way registerResource builds command names.
if (res?.customOperations) {
const spaced = Object.keys(res.customOperations).find((k) => k.replace(/ /g, '-') === verb);
const deepSpaced = spaced && renderVerbHelp(res, spaced);
if (deepSpaced) return deepSpaced;
}
}
return description;
}
/**
* Unknown-command error that carries its fix: if the command names a known
* resource, list that resource's verbs; otherwise suggest the closest
* registered command. Resource detection walks dash-prefixes longest-first,
* same as the ID fallback above, so multi-word plurals (messaging-groups,
* user-dms) resolve.
*/
function unknownCommandMessage(command: string): string {
const parts = command.split('-');
for (let i = parts.length; i > 0; i--) {
const prefix = parts.slice(0, i).join('-');
const res = getResource(prefix);
if (res) {
return (
`no command "${command}" — verbs for ${res.plural}: ${listVerbs(res).join(', ')}. ` +
`Run \`ncl ${res.plural} help <verb>\` for flags and examples.`
);
}
}
const names = listCommands()
.filter((c) => c.access !== 'hidden')
.map((c) => c.name);
const closest = closestName(command, names);
return `no command "${command}"${closest ? ` — did you mean "${closest}"?` : ''} Run \`ncl help\`.`;
}
/** Closest name by edit distance, only when convincingly close (≤2 edits). */
function closestName(input: string, names: string[]): string | undefined {
let best: string | undefined;
let bestDist = 3;
for (const name of names) {
if (Math.abs(name.length - input.length) >= bestDist) continue;
const d = editDistance(input, name, bestDist);
if (d < bestDist) {
bestDist = d;
best = name;
}
}
return best;
}
function editDistance(a: string, b: string, cap: number): number {
const prev = new Array(b.length + 1).fill(0).map((_, i) => i);
for (let i = 1; i <= a.length; i++) {
let diag = prev[0];
prev[0] = i;
let rowMin = prev[0];
for (let j = 1; j <= b.length; j++) {
const tmp = prev[j];
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
diag = tmp;
rowMin = Math.min(rowMin, prev[j]);
}
if (rowMin >= cap) return cap;
}
return prev[b.length];
}
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
return { id, ok: false, error: { code, message } };
}
+8 -1
View File
@@ -18,7 +18,14 @@ export type RequestFrame = {
};
export type ResponseFrame =
| { id: string; ok: true; data: unknown }
// `human` is an optional server-rendered presentational string. It lets
// every transport — host CLI and the Bun container client — print one
// canonical rendering without importing host-only formatters (the two
// runtimes share no modules, so client-side formatters drift). `data`
// stays the machine contract; --json callers ignore `human`. Additive:
// old clients that don't know the field just fall back to their own
// rendering of `data`.
| { id: string; ok: true; data: unknown; human?: string }
| { id: string; ok: false; error: { code: ErrorCode; message: string } };
export type ErrorCode =
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import type { ResourceDef } from './crud.js';
import { listVerbs, renderVerbHelp, summaryLine } from './help-render.js';
const res: ResourceDef = {
name: 'widget',
plural: 'widgets',
table: 'widgets',
description: 'Test widgets.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{ name: 'name', type: 'string', description: 'Display name.', required: true, updatable: true },
{ name: 'size', type: 'string', description: 'Widget size.', enum: ['s', 'm', 'l'], default: 'm' },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
customOperations: {
ping: {
access: 'open',
description: 'Ping a widget.\nLonger prose that only deep help shows.',
args: [
{ name: 'target', type: 'string', description: 'Where to ping.', required: true },
{ name: 'count', type: 'number', description: 'How many times.', default: 1 },
],
examples: ['ncl widgets ping --target prod --count 3'],
handler: async () => ({}),
},
},
};
describe('listVerbs', () => {
it('lists enabled generics then custom verbs', () => {
expect(listVerbs(res)).toEqual(['list', 'get', 'create', 'update', 'ping']);
});
});
describe('renderVerbHelp — custom operation', () => {
it('renders usage, full description, flags with tags, and examples', () => {
const out = renderVerbHelp(res, 'ping')!;
expect(out).toContain('ncl widgets ping');
expect(out).toContain('Longer prose that only deep help shows.');
expect(out).toContain('--target');
expect(out).toContain('(required)');
expect(out).toContain('--count');
expect(out).toContain('default: 1');
expect(out).toContain('Examples:');
expect(out).toContain('ncl widgets ping --target prod --count 3');
});
it('tags non-open access on the usage line', () => {
const gated: ResourceDef = {
...res,
customOperations: { ping: { ...res.customOperations!.ping, access: 'approval' } },
};
expect(renderVerbHelp(gated, 'ping')).toContain('ncl widgets ping [approval]');
});
});
describe('renderVerbHelp — generic verbs', () => {
it('create renders non-generated columns as flags', () => {
const out = renderVerbHelp(res, 'create')!;
expect(out).toContain('ncl widgets create [approval]');
expect(out).toContain('--name');
expect(out).toContain('(required)');
expect(out).toContain('--size');
expect(out).toContain('values: s | m | l');
expect(out).not.toContain('--id'); // generated
});
it('update renders only updatable columns and takes <id>', () => {
const out = renderVerbHelp(res, 'update')!;
expect(out).toContain('ncl widgets update <id> [approval]');
expect(out).toContain('--name');
expect(out).not.toContain('--size');
});
it('list renders filter flags plus --limit, never marked required', () => {
const out = renderVerbHelp(res, 'list')!;
expect(out).toContain('--limit');
expect(out).toContain('--name');
expect(out).not.toContain('(required)');
});
it('returns undefined for verbs the resource does not have', () => {
expect(renderVerbHelp(res, 'delete')).toBeUndefined(); // not in operations
expect(renderVerbHelp(res, 'bogus')).toBeUndefined();
});
});
describe('summaryLine', () => {
it('returns only the first line', () => {
expect(summaryLine('Ping a widget.\nLonger prose.')).toBe('Ping a widget.');
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Pure renderers for command help. Single source for three surfaces that must
* never disagree:
* - `ncl <resource> help [<verb>]` (commands/help.ts)
* - `--help` on any command (dispatch interception)
* - the usage block appended to invalid-args errors (crud.ts validation)
*
* Imports only types from crud.ts, so crud.ts can import these functions at
* runtime without a cycle.
*/
import type { ColumnDef, CustomOperation, ResourceDef } from './crud.js';
const GENERIC_VERBS = ['list', 'get', 'create', 'update', 'delete'] as const;
type GenericVerb = (typeof GENERIC_VERBS)[number];
export function flagName(col: Pick<ColumnDef, 'name'>): string {
return `--${col.name.replace(/_/g, '-')}`;
}
/** First line of a possibly multi-paragraph description. */
export function summaryLine(description: string): string {
return description.split('\n', 1)[0];
}
/** Indent every non-empty line of a block by `pad`. */
export function indent(text: string, pad: string): string {
return text
.split('\n')
.map((l) => (l ? pad + l : l))
.join('\n');
}
function flagLine(col: ColumnDef, extraTags: string[] = []): string {
const tags: string[] = [...extraTags];
if (col.required) tags.push('required');
if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`);
if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`);
const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : '';
return ` ${flagName(col).padEnd(28)} ${summaryLine(col.description)}${tagStr}`;
}
/** All verbs a resource exposes, generics first, in help order. */
export function listVerbs(res: ResourceDef): string[] {
const verbs: string[] = GENERIC_VERBS.filter((v) => res.operations[v]);
if (res.customOperations) verbs.push(...Object.keys(res.customOperations));
return verbs;
}
/** Flags a generic verb accepts, derived from the resource's columns. */
function genericFlags(res: ResourceDef, verb: GenericVerb): ColumnDef[] {
switch (verb) {
case 'create':
return res.columns.filter((c) => !c.generated);
case 'update':
return res.columns.filter((c) => c.updatable);
case 'list':
// Non-generated columns double as equality filters.
return [
...res.columns.filter((c) => !c.generated).map((c) => ({ ...c, required: false })),
{ name: 'limit', type: 'number', description: 'Max rows returned.', default: 200 } as ColumnDef,
];
case 'get':
case 'delete':
return [];
}
}
function genericSummary(res: ResourceDef, verb: GenericVerb): string {
switch (verb) {
case 'list':
return `List ${res.plural}. Flags below act as equality filters.`;
case 'get':
return `Get a ${res.name} by ID.`;
case 'create':
return `Create a new ${res.name}.`;
case 'update':
return `Update a ${res.name} by ID. Provide at least one updatable flag.`;
case 'delete':
return `Delete a ${res.name} by ID.`;
}
}
/**
* Deep help for one verb: usage line, full description, flags, examples.
* `verb` is a custom-operation key or a generic CRUD verb. Returns undefined
* for a verb the resource doesn't have.
*/
export function renderVerbHelp(res: ResourceDef, verb: string): string | undefined {
const op: CustomOperation | undefined = res.customOperations?.[verb];
const generic = !op && (GENERIC_VERBS as readonly string[]).includes(verb) ? (verb as GenericVerb) : undefined;
if (!op && !generic) return undefined;
if (generic && !res.operations[generic]) return undefined;
const access = op ? op.access : res.operations[generic!];
const accessTag = access && access !== 'open' ? ` [${access}]` : '';
const needsId = generic === 'get' || generic === 'update' || generic === 'delete';
const lines: string[] = [];
lines.push(`ncl ${res.plural} ${verb}${needsId ? ' <id>' : ''}${accessTag}`);
lines.push('');
lines.push(op ? op.description : genericSummary(res, generic!));
const flags = op ? (op.args ?? []) : genericFlags(res, generic!);
if (flags.length > 0) {
lines.push('');
lines.push('Flags:');
for (const f of flags) lines.push(flagLine(f));
}
if (op?.examples?.length) {
lines.push('');
lines.push('Examples:');
for (const ex of op.examples) lines.push(indent(ex, ' '));
}
return lines.join('\n');
}
+16 -1
View File
@@ -10,7 +10,14 @@
*/
import type { CallerContext } from './frame.js';
export type Access = 'open' | 'approval';
/**
* Resources an agent under `cli_scope=group` may touch. Single source
* consumed by both dispatch enforcement and `ncl help` filtering, so the
* agent is never shown a resource the gate would reject (or vice versa).
*/
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
export type Access = 'open' | 'approval' | 'hidden';
export type CommandDef<TArgs = unknown, TData = unknown> = {
name: string;
@@ -34,6 +41,14 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
parseArgs: (raw: Record<string, unknown>) => TArgs;
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
/**
* Optional presentational renderer. When set, dispatch attaches its output
* as the response frame's `human` field (server-rendered once, printed
* verbatim by every client in human mode). Runs after post-handler scope
* filtering, so it only ever sees data the caller is allowed to see. A
* throwing formatter is ignored clients fall back to rendering `data`.
*/
formatHuman?: (data: TData) => string;
};
const registry = new Map<string, CommandDef>();