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:
71
src/cli/socket-client.ts
Normal file
71
src/cli/socket-client.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* SocketTransport — client side. Used by the `nc` binary when running on
|
||||
* the host (i.e. invoked from a shell or by Claude in the project).
|
||||
*
|
||||
* Wire format: line-delimited JSON. One request per connection; the server
|
||||
* writes one response and closes.
|
||||
*/
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import type { RequestFrame, ResponseFrame } from './frame.js';
|
||||
import type { Transport } from './transport.js';
|
||||
|
||||
export const DEFAULT_SOCKET_PATH = path.join(DATA_DIR, 'nc.sock');
|
||||
|
||||
export class SocketTransport implements Transport {
|
||||
constructor(private readonly socketPath: string = DEFAULT_SOCKET_PATH) {}
|
||||
|
||||
async sendFrame(req: RequestFrame): Promise<ResponseFrame> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = net.createConnection(this.socketPath);
|
||||
let buffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settle = (
|
||||
action: 'resolve' | 'reject',
|
||||
valueOrErr: ResponseFrame | Error,
|
||||
): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
client.end();
|
||||
} catch (_e) {
|
||||
// best-effort
|
||||
}
|
||||
if (action === 'resolve') resolve(valueOrErr as ResponseFrame);
|
||||
else reject(valueOrErr as Error);
|
||||
};
|
||||
|
||||
client.on('connect', () => {
|
||||
client.write(JSON.stringify(req) + '\n');
|
||||
});
|
||||
|
||||
client.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
const idx = buffer.indexOf('\n');
|
||||
if (idx < 0) return;
|
||||
const line = buffer.slice(0, idx);
|
||||
try {
|
||||
const frame = JSON.parse(line) as ResponseFrame;
|
||||
settle('resolve', frame);
|
||||
} catch (e) {
|
||||
settle(
|
||||
'reject',
|
||||
new Error(
|
||||
`malformed response from host: ${e instanceof Error ? e.message : String(e)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (err) => settle('reject', err));
|
||||
client.on('close', () => {
|
||||
if (!settled) {
|
||||
settle('reject', new Error('host closed connection before sending response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user