From 3a3d2ee644db223aaf3f1aec8421331993e09e5e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:16 +0300 Subject: [PATCH] feat(cli): scaffold `nc` CLI with `list-groups` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bin/nc | 27 +++++++ src/cli/client.ts | 131 ++++++++++++++++++++++++++++++++ src/cli/commands/index.ts | 4 + src/cli/commands/list-groups.ts | 17 +++++ src/cli/dispatch.ts | 52 +++++++++++++ src/cli/format.ts | 55 ++++++++++++++ src/cli/frame.ts | 44 +++++++++++ src/cli/registry.ts | 36 +++++++++ src/cli/socket-client.ts | 71 +++++++++++++++++ src/cli/socket-server.ts | 116 ++++++++++++++++++++++++++++ src/cli/transport.ts | 10 +++ src/index.ts | 9 +++ 12 files changed, 572 insertions(+) create mode 100755 bin/nc create mode 100644 src/cli/client.ts create mode 100644 src/cli/commands/index.ts create mode 100644 src/cli/commands/list-groups.ts create mode 100644 src/cli/dispatch.ts create mode 100644 src/cli/format.ts create mode 100644 src/cli/frame.ts create mode 100644 src/cli/registry.ts create mode 100644 src/cli/socket-client.ts create mode 100644 src/cli/socket-server.ts create mode 100644 src/cli/transport.ts diff --git a/bin/nc b/bin/nc new file mode 100755 index 000000000..caceb421c --- /dev/null +++ b/bin/nc @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# nc — NanoClaw CLI launcher. +# +# Resolves the project root from this script's location, cd's there so the +# host-resolved DATA_DIR matches the running host, and execs the TS entry +# via tsx. Symlink this file into a directory on your PATH (or alias `nc` +# to its full path) to invoke from anywhere: +# +# ln -s "$(pwd)/bin/nc" /usr/local/bin/nc +# # or +# alias nc="$(pwd)/bin/nc" + +set -euo pipefail + +SCRIPT="${BASH_SOURCE[0]}" +# Resolve symlinks so PROJECT_ROOT points at the real checkout. +while [ -h "$SCRIPT" ]; do + DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)" + SCRIPT="$(readlink "$SCRIPT")" + [[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT" +done +SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" +exec pnpm exec tsx src/cli/client.ts "$@" diff --git a/src/cli/client.ts b/src/cli/client.ts new file mode 100644 index 000000000..dab5ef382 --- /dev/null +++ b/src/cli/client.ts @@ -0,0 +1,131 @@ +/** + * `nc` binary entry point. + * + * Parses argv, builds a request frame, sends it via the picked transport, + * formats the response, exits non-zero on error. + * + * Usage: + * nc [--key value ...] [--json] + * + * Examples: + * nc list-groups + * nc list groups # space-separated form is auto-joined + * nc list-groups --json + */ +import { randomUUID } from 'crypto'; + +import { formatResponse } from './format.js'; +import type { RequestFrame } from './frame.js'; +import { SocketTransport } from './socket-client.js'; +import type { Transport } from './transport.js'; + +async function main(): Promise { + const argv = process.argv.slice(2); + + if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + printUsage(); + process.exit(0); + } + + const { command, args, json } = parseArgv(argv); + const req: RequestFrame = { id: randomUUID(), command, args }; + const transport: Transport = pickTransport(); + + let res; + try { + res = await transport.sendFrame(req); + } catch (e) { + process.stderr.write(formatTransportError(e)); + process.exit(2); + } + + process.stdout.write(formatResponse(res, json ? 'json' : 'human')); + process.exit(res.ok ? 0 : 1); +} + +function pickTransport(): Transport { + // Container DB transport will land alongside the agent-runner change. + // For now: host-only — the only callers are a shell user or Claude in + // the project. + return new SocketTransport(); +} + +function parseArgv(argv: string[]): { + command: string; + args: Record; + json: boolean; +} { + const positional: string[] = []; + const args: Record = {}; + let json = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') { + json = true; + continue; + } + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + continue; + } + positional.push(a); + } + + if (positional.length === 0) { + process.stderr.write('nc: missing command\n'); + printUsage(); + process.exit(2); + } + + // Allow `nc list groups` as well as `nc list-groups`. Server rejects + // unknowns, so the naive join is safe — at worst the user gets an + // unknown-command error. + const command = + positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + + return { command, args, json }; +} + +function printUsage(): void { + process.stdout.write( + [ + 'Usage: nc [--key value ...] [--json]', + '', + 'Commands:', + ' list-groups List all agent groups.', + '', + 'Run `nc --json` for machine-readable output.', + '', + ].join('\n'), + ); +} + +function formatTransportError(e: unknown): string { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) { + return [ + `nc: cannot reach NanoClaw host (${msg}).`, + `Is the host running? Start it with: pnpm run dev`, + `Or, if installed as a service:`, + ` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`, + ` Linux: systemctl --user restart nanoclaw`, + ``, + ].join('\n'); + } + return `nc: transport error: ${msg}\n`; +} + +main().catch((err) => { + process.stderr.write( + `nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(2); +}); diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts new file mode 100644 index 000000000..f37e5caa1 --- /dev/null +++ b/src/cli/commands/index.ts @@ -0,0 +1,4 @@ +// Side-effect imports — each command file calls register() at top level. +// Imported by src/index.ts on host startup so the registry is populated +// before the CLI server accepts connections. +import './list-groups.js'; diff --git a/src/cli/commands/list-groups.ts b/src/cli/commands/list-groups.ts new file mode 100644 index 000000000..98b87f1e5 --- /dev/null +++ b/src/cli/commands/list-groups.ts @@ -0,0 +1,17 @@ +import { getAllAgentGroups } from '../../db/agent-groups.js'; +import { register } from '../registry.js'; + +register({ + name: 'list-groups', + description: 'List all agent groups.', + riskClass: 'safe', + parseArgs: () => ({}), + handler: async () => + getAllAgentGroups().map((g) => ({ + id: g.id, + name: g.name, + folder: g.folder, + provider: g.agent_provider ?? 'claude', + created_at: g.created_at, + })), +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts new file mode 100644 index 000000000..62c001bc9 --- /dev/null +++ b/src/cli/dispatch.ts @@ -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 { + 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); +} diff --git a/src/cli/format.ts b/src/cli/format.ts new file mode 100644 index 000000000..c468143aa --- /dev/null +++ b/src/cli/format.ts @@ -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[]); + } + return JSON.stringify(data, null, 2); +} + +function isFlatRecord(x: unknown): x is Record { + if (!x || typeof x !== 'object') return false; + for (const v of Object.values(x as Record)) { + if (v !== null && typeof v === 'object') return false; + } + return true; +} + +function renderTable(rows: Record[]): 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'); +} diff --git a/src/cli/frame.ts b/src/cli/frame.ts new file mode 100644 index 000000000..8e7604aea --- /dev/null +++ b/src/cli/frame.ts @@ -0,0 +1,44 @@ +/** + * Wire format shared between the socket transport (host caller) and — when + * it lands — the DB transport (container agent caller). + * + * Same JSON whether it goes over a socket as a line or sits in a + * `frame_json TEXT` column on a session DB. Caller identity is NOT carried + * in the frame — it's filled in by whichever server-side adapter received + * the bytes (see CallerContext). + */ + +export type RequestFrame = { + /** Correlation key set by the client. */ + id: string; + /** Registry name, e.g. "list-groups". */ + command: string; + /** Command-specific. Each command's parseArgs validates. */ + args: Record; +}; + +export type ResponseFrame = + | { id: string; ok: true; data: unknown } + | { id: string; ok: false; error: { code: ErrorCode; message: string } }; + +export type ErrorCode = + | 'unknown-command' + | 'invalid-args' + | 'permission-denied' + | 'approval-pending' + | 'not-found' + | 'handler-error' + | 'transport-error'; + +/** + * Filled in by the transport adapter on the server side. Handlers read + * caller identity from here, never from the frame. + */ +export type CallerContext = + | { caller: 'host' } + | { + caller: 'agent'; + sessionId: string; + agentGroupId: string; + messagingGroupId: string; + }; diff --git a/src/cli/registry.ts b/src/cli/registry.ts new file mode 100644 index 000000000..bd224c9f8 --- /dev/null +++ b/src/cli/registry.ts @@ -0,0 +1,36 @@ +/** + * Command registry — single source of truth for what `nc` can do. + * + * Each command file under `commands/` calls `register()` at top level, + * and `commands/index.ts` imports them all for side effects so the + * registry is populated before the host's CLI server accepts connections. + */ +import type { CallerContext } from './frame.js'; + +export type RiskClass = 'safe' | 'requires-admin' | 'requires-owner'; + +export type CommandDef = { + name: string; + description: string; + riskClass: RiskClass; + /** Validates `frame.args` and produces the typed handler input. Throws on invalid. */ + parseArgs: (raw: Record) => TArgs; + handler: (args: TArgs, ctx: CallerContext) => Promise; +}; + +const registry = new Map(); + +export function register(def: CommandDef): void { + if (registry.has(def.name)) { + throw new Error(`CLI command "${def.name}" already registered`); + } + registry.set(def.name, def as CommandDef); +} + +export function lookup(name: string): CommandDef | undefined { + return registry.get(name); +} + +export function listCommands(): CommandDef[] { + return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts new file mode 100644 index 000000000..1931cb068 --- /dev/null +++ b/src/cli/socket-client.ts @@ -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 { + 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')); + } + }); + }); + } +} diff --git a/src/cli/socket-server.ts b/src/cli/socket-server.ts new file mode 100644 index 000000000..d77ce2569 --- /dev/null +++ b/src/cli/socket-server.ts @@ -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 { + // 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((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 { + if (!server) return; + const s = server; + server = null; + await new Promise((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 { + 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; + return ( + typeof o.id === 'string' && + typeof o.command === 'string' && + typeof o.args === 'object' && + o.args !== null + ); +} diff --git a/src/cli/transport.ts b/src/cli/transport.ts new file mode 100644 index 000000000..b2631021b --- /dev/null +++ b/src/cli/transport.ts @@ -0,0 +1,10 @@ +/** + * Client-side transport interface. The `nc` binary picks one of these and + * calls sendFrame; the caller doesn't know whether bytes traveled over a + * Unix socket (host) or through outbound.db / inbound.db rows (container). + */ +import type { RequestFrame, ResponseFrame } from './frame.js'; + +export interface Transport { + sendFrame(req: RequestFrame): Promise; +} diff --git a/src/index.ts b/src/index.ts index ea9fba63c..b2f6b61b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,11 @@ import './channels/index.js'; // append registry-based modules. Imported for side effects (registrations). import './modules/index.js'; +// CLI command barrel — populates the `nc` registry before the CLI server +// accepts connections. +import './cli/commands/index.js'; +import { startCliServer, stopCliServer } from './cli/socket-server.js'; + import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; @@ -159,6 +164,9 @@ async function main(): Promise { startHostSweep(); log.info('Host sweep started'); + // 7. Start the `nc` CLI socket server (data/nc.sock). + await startCliServer(); + log.info('NanoClaw running'); } @@ -174,6 +182,7 @@ async function shutdown(signal: string): Promise { } stopDeliveryPolls(); stopHostSweep(); + await stopCliServer(); await teardownChannelAdapters(); process.exit(0); }