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:
52
src/cli/dispatch.ts
Normal file
52
src/cli/dispatch.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user