feat(cli): scaffold nc CLI with list-groups command

Adds a transport-agnostic CLI control plane shared between three eventual
callers (host shell, Claude in project, container agent) — though only the
host-side socket transport is wired in this commit. Container DB transport
and approval flow land alongside the first risky command.

- src/cli/frame.ts:        wire format (RequestFrame, ResponseFrame, CallerContext)
- src/cli/registry.ts:     command registry with RiskClass
- src/cli/dispatch.ts:     transport-agnostic dispatcher
- src/cli/transport.ts:    Transport interface
- src/cli/socket-client.ts: SocketTransport against data/nc.sock
- src/cli/socket-server.ts: host-side listener (chmod 0600, line-delimited JSON)
- src/cli/format.ts:       human table / --json output modes
- src/cli/client.ts:       `nc` argv -> frame -> transport -> stdout
- src/cli/commands/list-groups.ts: first command (riskClass: safe)
- bin/nc:                  bash launcher (resolves project root via symlink)
- src/index.ts:            start/stop server + import command barrel

`data/nc.sock` is intentionally separate from `data/cli.sock` (which the
existing chat-style channel adapter still owns).

Verified end-to-end: `nc list-groups`, `nc list groups`, `--json`,
unknown-command error, host-down ENOENT message with start instructions.
typecheck clean; eslint reports only the same `no-catch-all` warnings the
rest of the codebase has.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-29 18:03:16 +03:00
parent ae9bcb7c33
commit 3a3d2ee644
12 changed files with 572 additions and 0 deletions

52
src/cli/dispatch.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Transport-agnostic dispatcher. Both the socket server (host caller) and
* — once it lands — the per-session DB poller (container caller) call
* dispatch() with the same frame and a transport-supplied CallerContext.
*
* Approval gating for risky calls from the container is the only branch
* that differs by caller. Host callers and `safe` commands run inline.
*/
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { lookup } from './registry.js';
export async function dispatch(
req: RequestFrame,
ctx: CallerContext,
): Promise<ResponseFrame> {
const cmd = lookup(req.command);
if (!cmd) {
return err(req.id, 'unknown-command', `no command "${req.command}"`);
}
// Agent + risky → approval flow. Wired alongside the first risky command;
// until then, return a clear pending-shaped error so the contract is visible.
if (ctx.caller !== 'host' && cmd.riskClass !== 'safe') {
return err(
req.id,
'approval-pending',
'Approval flow not yet wired. (Will be added when the first risky command lands.)',
);
}
let parsed: unknown;
try {
parsed = cmd.parseArgs(req.args);
} catch (e) {
return err(req.id, 'invalid-args', errMsg(e));
}
try {
const data = await cmd.handler(parsed, ctx);
return { id: req.id, ok: true, data };
} catch (e) {
return err(req.id, 'handler-error', errMsg(e));
}
}
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
return { id, ok: false, error: { code, message } };
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}