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:
55
src/cli/format.ts
Normal file
55
src/cli/format.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Output formatting for the `nc` binary. Two modes:
|
||||
* - human (default): a small auto-table for arrays of flat records,
|
||||
* JSON.stringify for everything else, plain "error: ..." line for !ok.
|
||||
* - json: the response frame, pretty-printed.
|
||||
*
|
||||
* The MCP / agent side will always pass --json so it parses the frame
|
||||
* itself. The DB transport (when it lands) skips this layer entirely —
|
||||
* the agent sees frames directly.
|
||||
*/
|
||||
import type { ResponseFrame } from './frame.js';
|
||||
|
||||
export type FormatMode = 'human' | 'json';
|
||||
|
||||
export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
|
||||
if (mode === 'json') return JSON.stringify(res, null, 2) + '\n';
|
||||
|
||||
if (!res.ok) {
|
||||
return `error (${res.error.code}): ${res.error.message}\n`;
|
||||
}
|
||||
return formatHuman(res.data) + '\n';
|
||||
}
|
||||
|
||||
function formatHuman(data: unknown): string {
|
||||
if (data === null || data === undefined) return '';
|
||||
if (typeof data === 'string') return data;
|
||||
if (Array.isArray(data) && data.every(isFlatRecord)) {
|
||||
return renderTable(data as Record<string, unknown>[]);
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function isFlatRecord(x: unknown): x is Record<string, unknown> {
|
||||
if (!x || typeof x !== 'object') return false;
|
||||
for (const v of Object.values(x as Record<string, unknown>)) {
|
||||
if (v !== null && typeof v === 'object') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderTable(rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) return '(no rows)';
|
||||
const cols = Object.keys(rows[0]);
|
||||
const widths = cols.map((c) =>
|
||||
Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)),
|
||||
);
|
||||
const fmtRow = (vals: string[]): string =>
|
||||
vals.map((v, i) => v.padEnd(widths[i])).join(' ');
|
||||
const lines = [
|
||||
fmtRow(cols),
|
||||
fmtRow(widths.map((w) => '─'.repeat(w))),
|
||||
...rows.map((r) => fmtRow(cols.map((c) => String(r[c] ?? '')))),
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user