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:
116
src/cli/socket-server.ts
Normal file
116
src/cli/socket-server.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Host-side socket listener. Started from src/index.ts, accepts one frame
|
||||
* per connection, calls dispatch() with caller='host', writes the response
|
||||
* frame, closes.
|
||||
*
|
||||
* Lives at data/nc.sock (separate from data/cli.sock, which the existing
|
||||
* chat-style CLI channel adapter owns). Socket file is chmod 0600 — only
|
||||
* the user that started the host can connect.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
|
||||
import { log } from '../log.js';
|
||||
import { dispatch } from './dispatch.js';
|
||||
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { DEFAULT_SOCKET_PATH } from './socket-client.js';
|
||||
|
||||
let server: net.Server | null = null;
|
||||
|
||||
export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): Promise<void> {
|
||||
// Stale-socket cleanup — a previous run that crashed may have left the
|
||||
// file behind, and net.createServer refuses to bind to an existing path.
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code !== 'ENOENT') {
|
||||
log.warn('Failed to unlink stale nc socket (will try to bind anyway)', { socketPath, err });
|
||||
}
|
||||
}
|
||||
|
||||
const s = net.createServer((conn) => handleConnection(conn));
|
||||
server = s;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
s.once('error', reject);
|
||||
s.listen(socketPath, () => {
|
||||
try {
|
||||
fs.chmodSync(socketPath, 0o600);
|
||||
} catch (err) {
|
||||
log.warn('Failed to chmod nc socket (continuing)', { socketPath, err });
|
||||
}
|
||||
log.info('nc CLI server listening', { socketPath });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopCliServer(): Promise<void> {
|
||||
if (!server) return;
|
||||
const s = server;
|
||||
server = null;
|
||||
await new Promise<void>((resolve) => s.close(() => resolve()));
|
||||
}
|
||||
|
||||
function handleConnection(conn: net.Socket): void {
|
||||
let buffer = '';
|
||||
conn.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
void handleFrame(conn, line);
|
||||
}
|
||||
});
|
||||
conn.on('error', (err) => {
|
||||
log.warn('nc CLI server connection error', { err });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFrame(conn: net.Socket, line: string): Promise<void> {
|
||||
let req: RequestFrame;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (!isRequestFrame(parsed)) throw new Error('bad request shape');
|
||||
req = parsed;
|
||||
} catch (e) {
|
||||
write(conn, {
|
||||
id: 'unknown',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'transport-error',
|
||||
message: `bad frame: ${e instanceof Error ? e.message : String(e)}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Host caller — connecting to data/nc.sock requires file-system access
|
||||
// to a 0600 socket owned by the host user, so we treat the socket path
|
||||
// itself as the auth boundary.
|
||||
const ctx: CallerContext = { caller: 'host' };
|
||||
const res = await dispatch(req, ctx);
|
||||
write(conn, res);
|
||||
}
|
||||
|
||||
function write(conn: net.Socket, frame: ResponseFrame): void {
|
||||
try {
|
||||
conn.write(JSON.stringify(frame) + '\n');
|
||||
conn.end();
|
||||
} catch (err) {
|
||||
log.warn('Failed to write nc CLI response', { err });
|
||||
}
|
||||
}
|
||||
|
||||
function isRequestFrame(x: unknown): x is RequestFrame {
|
||||
if (!x || typeof x !== 'object') return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.command === 'string' &&
|
||||
typeof o.args === 'object' &&
|
||||
o.args !== null
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user