From 3a3d2ee644db223aaf3f1aec8421331993e09e5e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:16 +0300 Subject: [PATCH 01/13] 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 0000000..caceb42 --- /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 0000000..dab5ef3 --- /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 0000000..f37e5ca --- /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 0000000..98b87f1 --- /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 0000000..62c001b --- /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 0000000..c468143 --- /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 0000000..8e7604a --- /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 0000000..bd224c9 --- /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 0000000..1931cb0 --- /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 0000000..d77ce25 --- /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 0000000..b263102 --- /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 ea9fba6..b2f6b61 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); } From 594d1b4055c3486a203e339174afa81443b37835 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:47 +0300 Subject: [PATCH 02/13] style(cli): apply prettier formatting Pre-commit hook ran prettier on the prior commit but left the reformats unstaged. Folding them in here so the branch is clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/client.ts | 7 ++----- src/cli/dispatch.ts | 5 +---- src/cli/format.ts | 7 ++----- src/cli/socket-client.ts | 12 ++---------- src/cli/socket-server.ts | 7 +------ 5 files changed, 8 insertions(+), 30 deletions(-) diff --git a/src/cli/client.ts b/src/cli/client.ts index dab5ef3..db1e30a 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -88,8 +88,7 @@ function parseArgv(argv: string[]): { // 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]; + const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; return { command, args, json }; } @@ -124,8 +123,6 @@ function formatTransportError(e: unknown): string { } main().catch((err) => { - process.stderr.write( - `nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`, - ); + process.stderr.write(`nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(2); }); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 62c001b..6593750 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -9,10 +9,7 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { lookup } from './registry.js'; -export async function dispatch( - req: RequestFrame, - ctx: CallerContext, -): Promise { +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}"`); diff --git a/src/cli/format.ts b/src/cli/format.ts index c468143..5ce67c0 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -41,11 +41,8 @@ function isFlatRecord(x: unknown): x is Record { 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 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))), diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts index 1931cb0..c94a4dc 100644 --- a/src/cli/socket-client.ts +++ b/src/cli/socket-client.ts @@ -23,10 +23,7 @@ export class SocketTransport implements Transport { let buffer = ''; let settled = false; - const settle = ( - action: 'resolve' | 'reject', - valueOrErr: ResponseFrame | Error, - ): void => { + const settle = (action: 'resolve' | 'reject', valueOrErr: ResponseFrame | Error): void => { if (settled) return; settled = true; try { @@ -51,12 +48,7 @@ export class SocketTransport implements Transport { 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)}`, - ), - ); + settle('reject', new Error(`malformed response from host: ${e instanceof Error ? e.message : String(e)}`)); } }); diff --git a/src/cli/socket-server.ts b/src/cli/socket-server.ts index d77ce25..7ed2683 100644 --- a/src/cli/socket-server.ts +++ b/src/cli/socket-server.ts @@ -107,10 +107,5 @@ function write(conn: net.Socket, frame: ResponseFrame): void { 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 - ); + return typeof o.id === 'string' && typeof o.command === 'string' && typeof o.args === 'object' && o.args !== null; } From bc19b716bfc227d544712c07b0b0318dbf30b020 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 5 May 2026 23:48:39 +0300 Subject: [PATCH 03/13] feat(cli): wire nc CLI commands into container agent Add delivery action handler (cli_request) so the host dispatches CLI commands arriving from container agents via outbound.db and writes responses back to inbound.db. Add nc MCP tool in the agent-runner following the ask_user_question blocking pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/messages-in.ts | 24 +++++ container/agent-runner/src/mcp-tools/cli.ts | 97 +++++++++++++++++++ container/agent-runner/src/mcp-tools/index.ts | 1 + src/cli/delivery-action.ts | 59 +++++++++++ src/index.ts | 1 + 5 files changed, 182 insertions(+) create mode 100644 container/agent-runner/src/mcp-tools/cli.ts create mode 100644 src/cli/delivery-action.ts diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 88906ed..3fcb226 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -124,6 +124,30 @@ export function getMessageIn(id: string): MessageInRow | undefined { } } +/** + * Find a pending CLI response (by requestId in content). + * Reads from inbound.db, checks processing_ack to skip already-handled responses. + */ +export function findCliResponse(requestId: string): MessageInRow | undefined { + const inbound = openInboundDb(); + const outbound = getOutboundDb(); + + try { + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"requestId":"${requestId}"%`) as MessageInRow | undefined; + + if (!response) return undefined; + + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; + + return response; + } finally { + inbound.close(); + } +} + /** * Find a pending response to a question (by questionId in content). * Reads from inbound.db, checks processing_ack to skip already-handled responses. diff --git a/container/agent-runner/src/mcp-tools/cli.ts b/container/agent-runner/src/mcp-tools/cli.ts new file mode 100644 index 0000000..868eee3 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/cli.ts @@ -0,0 +1,97 @@ +/** + * CLI MCP tool — lets the container agent invoke host CLI commands. + * + * Follows the ask_user_question blocking pattern: writes a system message + * to outbound.db, polls inbound.db for the response. + */ +import { findCliResponse, markCompleted } from '../db/messages-in.js'; +import { writeMessageOut } from '../db/messages-out.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const ncCommand: McpToolDefinition = { + tool: { + name: 'nc', + description: + 'Run a NanoClaw CLI command on the host. Returns the command result as JSON. Use `nc list-groups` to see available agent groups. Run with command "help" to list all available commands.', + inputSchema: { + type: 'object' as const, + properties: { + command: { type: 'string', description: 'Command name (e.g. "list-groups")' }, + args: { + type: 'object', + description: 'Command arguments (command-specific)', + additionalProperties: true, + }, + timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' }, + }, + required: ['command'], + }, + }, + async handler(args) { + const command = args.command as string; + const commandArgs = (args.args as Record) ?? {}; + const timeout = ((args.timeout as number) || 30) * 1000; + + if (!command) { + return { content: [{ type: 'text' as const, text: 'Error: command is required' }], isError: true }; + } + + const requestId = generateId(); + + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'cli_request', + requestId, + command, + args: commandArgs, + }), + }); + + log(`nc: ${requestId} → ${command} ${JSON.stringify(commandArgs)}`); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const response = findCliResponse(requestId); + if (response) { + markCompleted([response.id]); + const parsed = JSON.parse(response.content); + const frame = parsed.frame; + + if (frame.ok) { + log(`nc response: ${requestId} → ok`); + return { content: [{ type: 'text' as const, text: JSON.stringify(frame.data, null, 2) }] }; + } else { + log(`nc response: ${requestId} → error: ${frame.error.message}`); + return { + content: [{ type: 'text' as const, text: `Error (${frame.error.code}): ${frame.error.message}` }], + isError: true, + }; + } + } + await sleep(500); + } + + log(`nc timeout: ${requestId}`); + return { + content: [{ type: 'text' as const, text: `CLI command timed out after ${timeout / 1000}s` }], + isError: true, + }; + }, +}; + +registerTools([ncCommand]); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index bdaef5c..672a637 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -10,6 +10,7 @@ import './scheduling.js'; import './interactive.js'; import './agents.js'; import './self-mod.js'; +import './cli.js'; import { startMcpServer } from './server.js'; function log(msg: string): void { diff --git a/src/cli/delivery-action.ts b/src/cli/delivery-action.ts new file mode 100644 index 0000000..5c693be --- /dev/null +++ b/src/cli/delivery-action.ts @@ -0,0 +1,59 @@ +/** + * Delivery action handler for CLI requests from container agents. + * + * When an agent writes a `cli_request` system message to outbound.db, + * the delivery poll picks it up and calls this handler. We dispatch + * the command and write the response back to inbound.db. + */ +import type Database from 'better-sqlite3'; + +import { registerDeliveryAction } from '../delivery.js'; +import { insertMessage } from '../db/session-db.js'; +import { log } from '../log.js'; +import { dispatch } from './dispatch.js'; +import type { RequestFrame } from './frame.js'; +import type { Session } from '../types.js'; + +registerDeliveryAction('cli_request', async (content, session, inDb) => { + const requestId = content.requestId as string; + const command = content.command as string; + const args = (content.args as Record) ?? {}; + + if (!requestId || !command) { + log.warn('cli_request missing requestId or command', { sessionId: session.id }); + return; + } + + const req: RequestFrame = { id: requestId, command, args }; + const ctx = { + caller: 'agent' as const, + sessionId: session.id, + agentGroupId: session.agent_group_id, + messagingGroupId: session.messaging_group_id ?? '', + }; + + log.info('CLI request from agent', { requestId, command, sessionId: session.id }); + + const response = await dispatch(req, ctx); + + // Write response to inbound.db so the container can read it. + // trigger=0: don't wake the agent — this is an inline response to a tool call. + insertMessage(inDb, { + id: `cli-resp-${requestId}`, + kind: 'system', + timestamp: new Date().toISOString(), + platformId: null, + channelType: null, + threadId: null, + content: JSON.stringify({ + type: 'cli_response', + requestId, + frame: response, + }), + processAfter: null, + recurrence: null, + trigger: 0, + }); + + log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id }); +}); diff --git a/src/index.ts b/src/index.ts index c13ef11..3d39dd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ import './modules/index.js'; // CLI command barrel — populates the `nc` registry before the CLI server // accepts connections. import './cli/commands/index.js'; +import './cli/delivery-action.js'; import { startCliServer, stopCliServer } from './cli/socket-server.js'; import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js'; From 5e2bf1cb54bfecbbd5b9a7643a32e0f173cafbdf Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:07:37 +0300 Subject: [PATCH 04/13] feat(cli): replace MCP tool with standalone nc client in container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the nc MCP tool in favor of a standalone Bun CLI script at container/agent-runner/src/cli/nc.ts. Same interface as host-side bin/nc — all three callers (operator, Claude on host, agent in container) now use the same nc CLI. Container transport: writes cli_request to outbound.db (BEGIN IMMEDIATE for seq safety), polls inbound.db for response, acks via processing_ack. Dockerfile adds a /usr/local/bin/nc wrapper that execs the mounted source. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/Dockerfile | 5 + container/agent-runner/src/cli/nc.ts | 253 ++++++++++++++++++ container/agent-runner/src/db/messages-in.ts | 24 -- container/agent-runner/src/mcp-tools/cli.ts | 97 ------- container/agent-runner/src/mcp-tools/index.ts | 1 - 5 files changed, 258 insertions(+), 122 deletions(-) create mode 100644 container/agent-runner/src/cli/nc.ts delete mode 100644 container/agent-runner/src/mcp-tools/cli.ts diff --git a/container/Dockerfile b/container/Dockerfile index efa58b6..d6b654a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -104,6 +104,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +# ---- nc CLI wrapper ---------------------------------------------------------- +# Actual script lives in the mounted source at /app/src/cli/nc.ts. +RUN printf '#!/bin/sh\nexec bun /app/src/cli/nc.ts "$@"\n' > /usr/local/bin/nc && \ + chmod +x /usr/local/bin/nc + # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/nc.ts new file mode 100644 index 0000000..319c63a --- /dev/null +++ b/container/agent-runner/src/cli/nc.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun +/** + * nc — NanoClaw CLI client (container edition). + * + * Same interface as the host-side `bin/nc`. Detects that it's inside a + * container (the session DBs exist at /workspace/) and uses a DB transport + * instead of the Unix socket transport. + * + * Writes a cli_request system message to outbound.db, polls inbound.db + * for the response. Self-contained — no imports from agent-runner. + */ +import { Database } from 'bun:sqlite'; + +// --------------------------------------------------------------------------- +// Frame types (mirrors src/cli/frame.ts on the host) +// --------------------------------------------------------------------------- + +type RequestFrame = { + id: string; + command: string; + args: Record; +}; + +type ResponseFrame = + | { id: string; ok: true; data: unknown } + | { id: string; ok: false; error: { code: string; message: string } }; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const INBOUND_DB = '/workspace/inbound.db'; +const OUTBOUND_DB = '/workspace/outbound.db'; + +// --------------------------------------------------------------------------- +// DB transport +// --------------------------------------------------------------------------- + +function generateId(): string { + return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Write a cli_request to outbound.db. + * + * Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq), + * preventing seq collisions with concurrent agent-runner writes. + */ +function writeRequest(req: RequestFrame): void { + const db = new Database(OUTBOUND_DB); + db.exec('PRAGMA journal_mode = DELETE'); + db.exec('PRAGMA busy_timeout = 5000'); + + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + + try { + db.exec('BEGIN IMMEDIATE'); + const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m; + const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; + const max = Math.max(maxOut, maxIn); + const nextSeq = max % 2 === 0 ? max + 1 : max + 2; + + db.prepare( + `INSERT INTO messages_out (id, seq, timestamp, kind, content) + VALUES ($id, $seq, datetime('now'), 'system', $content)`, + ).run({ + $id: req.id, + $seq: nextSeq, + $content: JSON.stringify({ + action: 'cli_request', + requestId: req.id, + command: req.command, + args: req.args, + }), + }); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } finally { + inDb.close(); + db.close(); + } +} + +/** + * Poll inbound.db for a cli_response matching our requestId. + * Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility. + */ +function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + inDb.exec('PRAGMA mmap_size = 0'); + + try { + const row = inDb + .prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null; + + if (row) { + inDb.close(); + + // Mark as completed via processing_ack so agent-runner skips it + const outDb = new Database(OUTBOUND_DB); + outDb.exec('PRAGMA journal_mode = DELETE'); + outDb.exec('PRAGMA busy_timeout = 5000'); + outDb + .prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + ) + .run(row.id); + outDb.close(); + + const parsed = JSON.parse(row.content); + return parsed.frame as ResponseFrame; + } + } finally { + try { inDb.close(); } catch {} + } + + Bun.sleepSync(500); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Arg parsing (mirrors host-side client.ts) +// --------------------------------------------------------------------------- + +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); + } + + 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]', '', 'Run `nc help` to list available commands.', ''].join('\n'), + ); +} + +// --------------------------------------------------------------------------- +// Formatting (mirrors src/cli/format.ts on the host) +// --------------------------------------------------------------------------- + +function formatHuman(resp: ResponseFrame): string { + if (!resp.ok) { + return `error (${resp.error.code}): ${resp.error.message}\n`; + } + + const data = resp.data; + if (!Array.isArray(data) || data.length === 0) { + return JSON.stringify(data, null, 2) + '\n'; + } + + const isFlat = data.every( + (r) => + typeof r === 'object' && + r !== null && + !Array.isArray(r) && + Object.values(r as Record).every((v) => typeof v !== 'object' || v === null), + ); + + if (!isFlat) return JSON.stringify(data, null, 2) + '\n'; + + const keys = Object.keys(data[0] as Record); + const widths = keys.map((k) => + Math.max(k.length, ...data.map((r) => String((r as Record)[k] ?? '').length)), + ); + + const header = keys.map((k, i) => k.padEnd(widths[i])).join(' '); + const sep = widths.map((w) => '-'.repeat(w)).join(' '); + const rows = data.map((r) => + keys + .map((k, i) => String((r as Record)[k] ?? '').padEnd(widths[i])) + .join(' '), + ); + + return [header, sep, ...rows, ''].join('\n'); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +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 requestId = generateId(); +const req: RequestFrame = { id: requestId, command, args }; + +writeRequest(req); + +const resp = pollResponse(requestId, 30_000); + +if (!resp) { + process.stderr.write('nc: command timed out after 30s\n'); + process.exit(2); +} + +if (json) { + process.stdout.write(JSON.stringify(resp, null, 2) + '\n'); +} else { + const output = formatHuman(resp); + if (!resp.ok) { + process.stderr.write(output); + process.exit(1); + } + process.stdout.write(output); +} diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 3fcb226..88906ed 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -124,30 +124,6 @@ export function getMessageIn(id: string): MessageInRow | undefined { } } -/** - * Find a pending CLI response (by requestId in content). - * Reads from inbound.db, checks processing_ack to skip already-handled responses. - */ -export function findCliResponse(requestId: string): MessageInRow | undefined { - const inbound = openInboundDb(); - const outbound = getOutboundDb(); - - try { - const response = inbound - .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") - .get(`%"requestId":"${requestId}"%`) as MessageInRow | undefined; - - if (!response) return undefined; - - const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); - if (acked) return undefined; - - return response; - } finally { - inbound.close(); - } -} - /** * Find a pending response to a question (by questionId in content). * Reads from inbound.db, checks processing_ack to skip already-handled responses. diff --git a/container/agent-runner/src/mcp-tools/cli.ts b/container/agent-runner/src/mcp-tools/cli.ts deleted file mode 100644 index 868eee3..0000000 --- a/container/agent-runner/src/mcp-tools/cli.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * CLI MCP tool — lets the container agent invoke host CLI commands. - * - * Follows the ask_user_question blocking pattern: writes a system message - * to outbound.db, polls inbound.db for the response. - */ -import { findCliResponse, markCompleted } from '../db/messages-in.js'; -import { writeMessageOut } from '../db/messages-out.js'; -import { registerTools } from './server.js'; -import type { McpToolDefinition } from './types.js'; - -function log(msg: string): void { - console.error(`[mcp-tools] ${msg}`); -} - -function generateId(): string { - return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export const ncCommand: McpToolDefinition = { - tool: { - name: 'nc', - description: - 'Run a NanoClaw CLI command on the host. Returns the command result as JSON. Use `nc list-groups` to see available agent groups. Run with command "help" to list all available commands.', - inputSchema: { - type: 'object' as const, - properties: { - command: { type: 'string', description: 'Command name (e.g. "list-groups")' }, - args: { - type: 'object', - description: 'Command arguments (command-specific)', - additionalProperties: true, - }, - timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' }, - }, - required: ['command'], - }, - }, - async handler(args) { - const command = args.command as string; - const commandArgs = (args.args as Record) ?? {}; - const timeout = ((args.timeout as number) || 30) * 1000; - - if (!command) { - return { content: [{ type: 'text' as const, text: 'Error: command is required' }], isError: true }; - } - - const requestId = generateId(); - - writeMessageOut({ - id: requestId, - kind: 'system', - content: JSON.stringify({ - action: 'cli_request', - requestId, - command, - args: commandArgs, - }), - }); - - log(`nc: ${requestId} → ${command} ${JSON.stringify(commandArgs)}`); - - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const response = findCliResponse(requestId); - if (response) { - markCompleted([response.id]); - const parsed = JSON.parse(response.content); - const frame = parsed.frame; - - if (frame.ok) { - log(`nc response: ${requestId} → ok`); - return { content: [{ type: 'text' as const, text: JSON.stringify(frame.data, null, 2) }] }; - } else { - log(`nc response: ${requestId} → error: ${frame.error.message}`); - return { - content: [{ type: 'text' as const, text: `Error (${frame.error.code}): ${frame.error.message}` }], - isError: true, - }; - } - } - await sleep(500); - } - - log(`nc timeout: ${requestId}`); - return { - content: [{ type: 'text' as const, text: `CLI command timed out after ${timeout / 1000}s` }], - isError: true, - }; - }, -}; - -registerTools([ncCommand]); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index 672a637..bdaef5c 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -10,7 +10,6 @@ import './scheduling.js'; import './interactive.js'; import './agents.js'; import './self-mod.js'; -import './cli.js'; import { startMcpServer } from './server.js'; function log(msg: string): void { From 68658111479da6255a5b3701cee2bdf3030b1eeb Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:33:10 +0300 Subject: [PATCH 05/13] feat(cli): add CRUD helper, resource definitions, and help command Resource-first CLI: `nc groups list`, `nc wirings get `, etc. Seven resources defined (groups, messaging-groups, wirings, users, roles, members, sessions) with full column documentation that serves as the single source of truth for help output and arg validation. - CRUD helper auto-registers list/get/create/update/delete from declarative resource definitions with generic SQL - Custom operations for composite-PK resources (roles grant/revoke, members add/remove) - Access model: open (reads) / approval (writes) / hidden - `nc help` lists resources; `nc help` shows fields - Positional target IDs: `nc groups get ` - Removed unused priority column from wirings Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/cli/nc.ts | 6 + src/cli/client.ts | 39 ++-- src/cli/commands/help.ts | 106 ++++++++++ src/cli/commands/index.ts | 14 +- src/cli/commands/list-groups.ts | 17 -- src/cli/crud.ts | 280 ++++++++++++++++++++++++++ src/cli/dispatch.ts | 14 +- src/cli/registry.ts | 6 +- src/cli/resources/groups.ts | 36 ++++ src/cli/resources/index.ts | 11 + src/cli/resources/members.ts | 65 ++++++ src/cli/resources/messaging-groups.ts | 50 +++++ src/cli/resources/roles.ts | 66 ++++++ src/cli/resources/sessions.ts | 44 ++++ src/cli/resources/users.ts | 33 +++ src/cli/resources/wirings.ts | 69 +++++++ 16 files changed, 810 insertions(+), 46 deletions(-) create mode 100644 src/cli/commands/help.ts delete mode 100644 src/cli/commands/list-groups.ts create mode 100644 src/cli/crud.ts create mode 100644 src/cli/resources/groups.ts create mode 100644 src/cli/resources/index.ts create mode 100644 src/cli/resources/members.ts create mode 100644 src/cli/resources/messaging-groups.ts create mode 100644 src/cli/resources/roles.ts create mode 100644 src/cli/resources/sessions.ts create mode 100644 src/cli/resources/users.ts create mode 100644 src/cli/resources/wirings.ts diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/nc.ts index 319c63a..cc3883e 100644 --- a/container/agent-runner/src/cli/nc.ts +++ b/container/agent-runner/src/cli/nc.ts @@ -168,6 +168,12 @@ function parseArgv(argv: string[]): { } const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + + // Third positional is the target ID + if (positional.length >= 3) { + args.id = positional[2]; + } + return { command, args, json }; } diff --git a/src/cli/client.ts b/src/cli/client.ts index db1e30a..36197ad 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -5,12 +5,15 @@ * formats the response, exits non-zero on error. * * Usage: - * nc [--key value ...] [--json] + * nc [target] [--key value ...] [--json] * * Examples: - * nc list-groups - * nc list groups # space-separated form is auto-joined - * nc list-groups --json + * nc groups list + * nc groups get abc123 + * nc groups create --name foo --folder bar + * nc groups update abc123 --name baz + * nc help + * nc groups help */ import { randomUUID } from 'crypto'; @@ -44,9 +47,6 @@ async function main(): Promise { } 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(); } @@ -85,10 +85,20 @@ function parseArgv(argv: string[]): { 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]; + // Single word: `nc help` + // Two words: `nc groups list`, `nc groups help` + // Three words: `nc groups get abc123` + let command: string; + if (positional.length === 1) { + command = positional[0]; + } else { + command = `${positional[0]}-${positional[1]}`; + } + + // Third positional is the target ID + if (positional.length >= 3) { + args.id = positional[2]; + } return { command, args, json }; } @@ -96,12 +106,9 @@ function parseArgv(argv: string[]): { function printUsage(): void { process.stdout.write( [ - 'Usage: nc [--key value ...] [--json]', + 'Usage: nc [target] [--key value ...] [--json]', '', - 'Commands:', - ' list-groups List all agent groups.', - '', - 'Run `nc --json` for machine-readable output.', + 'Run `nc help` to list available resources and commands.', '', ].join('\n'), ); diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts new file mode 100644 index 0000000..9219b70 --- /dev/null +++ b/src/cli/commands/help.ts @@ -0,0 +1,106 @@ +/** + * Built-in help command. Introspects the resource and command registries. + * + * nc help — list all resources and commands + * nc groups help — show group resource details (verbs, columns, enums) + */ +import { getResource, getResources } from '../crud.js'; +import { listCommands, register } from '../registry.js'; + +register({ + name: 'help', + description: 'List available resources and commands.', + access: 'open', + parseArgs: () => ({}), + handler: async () => { + const resources = getResources(); + const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource); + + const lines: string[] = []; + if (resources.length > 0) { + lines.push('Resources:'); + for (const r of resources) { + const ops: string[] = []; + if (r.operations.list) ops.push('list'); + if (r.operations.get) ops.push('get'); + if (r.operations.create) ops.push('create'); + if (r.operations.update) ops.push('update'); + if (r.operations.delete) ops.push('delete'); + if (r.customOperations) ops.push(...Object.keys(r.customOperations)); + lines.push(` ${r.plural.padEnd(20)} ${r.description}`); + lines.push(` ${''.padEnd(20)} verbs: ${ops.join(', ')}`); + } + } + + if (commands.length > 0) { + if (lines.length > 0) lines.push(''); + lines.push('Commands:'); + for (const c of commands) { + lines.push(` ${c.name.padEnd(20)} ${c.description}`); + } + } + + lines.push(''); + lines.push('Run `nc help` for detailed field information.'); + return lines.join('\n'); + }, +}); + +// Register per-resource help commands. These are registered dynamically +// after the resources barrel has been imported. +// We use a lazy approach: register a catch-all pattern isn't possible with +// the flat registry, so we register `-help` for each resource +// in a post-import hook. +export function registerResourceHelpCommands(): void { + for (const res of getResources()) { + // Skip if already registered (e.g. from a previous call) + try { + register({ + name: `${res.plural}-help`, + description: `Show ${res.name} resource details.`, + access: 'open', + resource: res.plural, + parseArgs: () => ({}), + handler: async () => { + const lines: string[] = []; + lines.push(`${res.plural}: ${res.description}`); + lines.push(''); + + // Verbs + const verbs: string[] = []; + if (res.operations.list) verbs.push(`list [open]`); + if (res.operations.get) verbs.push(`get [open]`); + if (res.operations.create) verbs.push(`create [approval]`); + if (res.operations.update) verbs.push(`update [approval]`); + if (res.operations.delete) verbs.push(`delete [approval]`); + if (res.customOperations) { + for (const [verb, op] of Object.entries(res.customOperations)) { + verbs.push(`${verb} [${op.access}] — ${op.description}`); + } + } + lines.push('Verbs:'); + for (const v of verbs) lines.push(` ${v}`); + lines.push(''); + + // Columns + lines.push('Fields:'); + for (const col of res.columns) { + const tags: string[] = []; + if (col.generated) tags.push('auto'); + if (col.required) tags.push('required'); + if (col.updatable) tags.push('updatable'); + if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`); + if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`); + + const flag = `--${col.name.replace(/_/g, '-')}`; + const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : ''; + lines.push(` ${flag.padEnd(28)} ${col.description}${tagStr}`); + } + return lines.join('\n'); + }, + }); + } catch { + // Already registered — skip + } + } +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index f37e5ca..5b05345 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,4 +1,10 @@ -// 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'; +/** + * Command barrel — populates the registry before the CLI server starts. + * + * Resource definitions register their CRUD commands on import. + * Help commands are registered after resources are loaded. + */ +import '../resources/index.js'; +import { registerResourceHelpCommands } from './help.js'; + +registerResourceHelpCommands(); diff --git a/src/cli/commands/list-groups.ts b/src/cli/commands/list-groups.ts deleted file mode 100644 index 98b87f1..0000000 --- a/src/cli/commands/list-groups.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/crud.ts b/src/cli/crud.ts new file mode 100644 index 0000000..370b9ad --- /dev/null +++ b/src/cli/crud.ts @@ -0,0 +1,280 @@ +/** + * CRUD registration helper. + * + * Takes a declarative resource definition (table, columns, access levels) + * and auto-registers list/get/create/update/delete commands in the CLI + * registry. Column metadata doubles as documentation — `nc help` + * is generated from the same definitions. + */ +import { randomUUID } from 'crypto'; + +import { getDb } from '../db/connection.js'; +import { register } from './registry.js'; +import type { CallerContext } from './frame.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Access = 'open' | 'approval' | 'hidden'; + +export interface ColumnDef { + name: string; + type: 'string' | 'number' | 'boolean' | 'json'; + description: string; + /** Auto-set on create — not user-provided. */ + generated?: boolean; + /** Must be provided on create (ignored if generated). */ + required?: boolean; + /** Can be changed via update. */ + updatable?: boolean; + /** Default value on create when not provided. */ + default?: unknown; + /** Allowed values (shown in help). */ + enum?: string[]; +} + +export interface CustomOperation { + access: Access; + description: string; + args?: ColumnDef[]; + handler: (args: Record, ctx: CallerContext) => Promise; +} + +export interface ResourceDef { + /** Singular name: 'group'. */ + name: string; + /** Plural name: 'groups'. Used in command names. */ + plural: string; + /** DB table name. */ + table: string; + /** One-line description shown in help. */ + description: string; + /** Primary key column name. */ + idColumn: string; + columns: ColumnDef[]; + /** Which standard CRUD operations are enabled. */ + operations: { + list?: Access; + get?: Access; + create?: Access; + update?: Access; + delete?: Access; + }; + /** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */ + customOperations?: Record; +} + +// --------------------------------------------------------------------------- +// Resource registry (for help introspection) +// --------------------------------------------------------------------------- + +const resources = new Map(); + +export function getResources(): ResourceDef[] { + return [...resources.values()].sort((a, b) => a.plural.localeCompare(b.plural)); +} + +export function getResource(plural: string): ResourceDef | undefined { + return resources.get(plural); +} + +// --------------------------------------------------------------------------- +// Generic SQL handlers +// --------------------------------------------------------------------------- + +function visibleColumns(def: ResourceDef): string[] { + return def.columns.map((c) => c.name); +} + +function genericList(def: ResourceDef) { + const cols = visibleColumns(def).join(', '); + return async () => { + return getDb().prepare(`SELECT ${cols} FROM ${def.table}`).all(); + }; +} + +function genericGet(def: ResourceDef) { + const cols = visibleColumns(def).join(', '); + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + const row = getDb() + .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) + .get(id); + if (!row) throw new Error(`${def.name} not found: ${id}`); + return row; + }; +} + +function genericCreate(def: ResourceDef) { + return async (args: Record) => { + const values: Record = {}; + + for (const col of def.columns) { + if (col.generated) { + if (col.name === def.idColumn) { + values[col.name] = randomUUID(); + } else if (col.name.endsWith('_at')) { + values[col.name] = new Date().toISOString(); + } + continue; + } + + const v = args[col.name]; + if (v !== undefined) { + if (col.enum && !col.enum.includes(String(v))) { + throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`); + } + values[col.name] = col.type === 'number' ? Number(v) : v; + } else if (col.required) { + throw new Error(`--${col.name.replace(/_/g, '-')} is required`); + } else if (col.default !== undefined) { + values[col.name] = col.default; + } + } + + const colNames = Object.keys(values); + const placeholders = colNames.map((c) => `@${c}`); + getDb() + .prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`) + .run(values); + return values; + }; +} + +function genericUpdate(def: ResourceDef) { + const updatableCols = def.columns.filter((c) => c.updatable); + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + + const updates: Record = {}; + for (const col of updatableCols) { + const v = args[col.name]; + if (v !== undefined) { + if (col.enum && !col.enum.includes(String(v))) { + throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`); + } + updates[col.name] = col.type === 'number' ? Number(v) : v; + } + } + if (Object.keys(updates).length === 0) { + throw new Error(`nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`); + } + + const setClause = Object.keys(updates) + .map((k) => `${k} = @${k}`) + .join(', '); + const result = getDb() + .prepare(`UPDATE ${def.table} SET ${setClause} WHERE ${def.idColumn} = @_id`) + .run({ ...updates, _id: id }); + if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); + + const cols = visibleColumns(def).join(', '); + return getDb() + .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) + .get(id); + }; +} + +function genericDelete(def: ResourceDef) { + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + const result = getDb() + .prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`) + .run(id); + if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); + return { deleted: id }; + }; +} + +// --------------------------------------------------------------------------- +// parseArgs helper: normalizes --hyphen-keys to underscore_keys +// --------------------------------------------------------------------------- + +function normalizeArgs(raw: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + out[k.replace(/-/g, '_')] = v; + } + return out; +} + +// --------------------------------------------------------------------------- +// registerResource +// --------------------------------------------------------------------------- + +export function registerResource(def: ResourceDef): void { + resources.set(def.plural, def); + + if (def.operations.list) { + register({ + name: `${def.plural}-list`, + description: `List all ${def.plural}.`, + access: def.operations.list, + resource: def.plural, + parseArgs: () => ({}), + handler: genericList(def), + }); + } + + if (def.operations.get) { + register({ + name: `${def.plural}-get`, + description: `Get a ${def.name} by ID.`, + access: def.operations.get, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericGet(def), + }); + } + + if (def.operations.create) { + register({ + name: `${def.plural}-create`, + description: `Create a new ${def.name}.`, + access: def.operations.create, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericCreate(def), + }); + } + + if (def.operations.update) { + register({ + name: `${def.plural}-update`, + description: `Update a ${def.name}.`, + access: def.operations.update, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericUpdate(def), + }); + } + + if (def.operations.delete) { + register({ + name: `${def.plural}-delete`, + description: `Delete a ${def.name}.`, + access: def.operations.delete, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericDelete(def), + }); + } + + // Custom operations + if (def.customOperations) { + for (const [verb, op] of Object.entries(def.customOperations)) { + register({ + name: `${def.plural}-${verb}`, + description: op.description, + access: op.access, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: async (args, ctx) => op.handler(args as Record, ctx), + }); + } + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6593750..a9943c4 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,10 +1,10 @@ /** * 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. + * 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. + * that differs by caller. Host callers and `open` commands run inline. */ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { lookup } from './registry.js'; @@ -15,13 +15,13 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise = { name: string; description: string; - riskClass: RiskClass; + access: Access; + /** Resource this command belongs to (for help grouping). */ + resource?: string; /** Validates `frame.args` and produces the typed handler input. Throws on invalid. */ parseArgs: (raw: Record) => TArgs; handler: (args: TArgs, ctx: CallerContext) => Promise; diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts new file mode 100644 index 0000000..5181bee --- /dev/null +++ b/src/cli/resources/groups.ts @@ -0,0 +1,36 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'group', + plural: 'groups', + table: 'agent_groups', + description: + 'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'name', + type: 'string', + description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.', + required: true, + updatable: true, + }, + { + name: 'folder', + type: 'string', + description: + 'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.', + required: true, + }, + { + name: 'agent_provider', + type: 'string', + description: 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', + updatable: true, + default: null, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); diff --git a/src/cli/resources/index.ts b/src/cli/resources/index.ts new file mode 100644 index 0000000..42155e7 --- /dev/null +++ b/src/cli/resources/index.ts @@ -0,0 +1,11 @@ +/** + * Resource barrel — imports each resource module for its side-effect + * `registerResource(...)` call. + */ +import './groups.js'; +import './messaging-groups.js'; +import './wirings.js'; +import './users.js'; +import './roles.js'; +import './members.js'; +import './sessions.js'; diff --git a/src/cli/resources/members.ts b/src/cli/resources/members.ts new file mode 100644 index 0000000..ac529be --- /dev/null +++ b/src/cli/resources/members.ts @@ -0,0 +1,65 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'member', + plural: 'members', + table: 'agent_group_members', + description: + 'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".', + idColumn: 'user_id', + columns: [ + { + name: 'user_id', + type: 'string', + description: 'The user to grant membership. Must reference an existing user (users.id).', + }, + { + name: 'agent_group_id', + type: 'string', + description: 'The agent group to grant access to. Must reference an existing agent group (agent_groups.id).', + }, + { + name: 'added_by', + type: 'string', + description: 'User ID of whoever added this member. Informational — not enforced.', + }, + { name: 'added_at', type: 'string', description: 'ISO 8601 timestamp of when the membership was granted.' }, + ], + operations: { list: 'open' }, + customOperations: { + add: { + access: 'approval', + description: 'Add a user as a member of an agent group. Use --user and --group.', + handler: async (args) => { + const userId = args.user as string; + const groupId = args.group as string; + const addedBy = (args.added_by as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!groupId) throw new Error('--group is required'); + getDb() + .prepare( + `INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES (?, ?, ?, datetime('now'))`, + ) + .run(userId, groupId, addedBy); + return { user_id: userId, agent_group_id: groupId }; + }, + }, + remove: { + access: 'approval', + description: 'Remove a user from an agent group. Use --user and --group.', + handler: async (args) => { + const userId = args.user as string; + const groupId = args.group as string; + if (!userId) throw new Error('--user is required'); + if (!groupId) throw new Error('--group is required'); + const result = getDb() + .prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .run(userId, groupId); + if (result.changes === 0) throw new Error('member not found'); + return { removed: { user_id: userId, agent_group_id: groupId } }; + }, + }, + }, +}); diff --git a/src/cli/resources/messaging-groups.ts b/src/cli/resources/messaging-groups.ts new file mode 100644 index 0000000..edccfc0 --- /dev/null +++ b/src/cli/resources/messaging-groups.ts @@ -0,0 +1,50 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'messaging-group', + plural: 'messaging-groups', + table: 'messaging_groups', + description: + 'Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'channel_type', + type: 'string', + description: 'Channel adapter type — matches the adapter registered by /add- (e.g. telegram, discord, slack, whatsapp).', + required: true, + }, + { + name: 'platform_id', + type: 'string', + description: + 'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.', + required: true, + }, + { + name: 'name', + type: 'string', + description: 'Display name. Often auto-populated by the channel adapter.', + updatable: true, + }, + { + name: 'is_group', + type: 'number', + description: 'Multi-user group chat (1) or direct message (0). Affects session scoping.', + default: 0, + updatable: true, + }, + { + name: 'unknown_sender_policy', + type: 'string', + description: + 'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.', + enum: ['strict', 'request_approval', 'public'], + default: 'strict', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); diff --git a/src/cli/resources/roles.ts b/src/cli/resources/roles.ts new file mode 100644 index 0000000..4e0a20b --- /dev/null +++ b/src/cli/resources/roles.ts @@ -0,0 +1,66 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'role', + plural: 'roles', + table: 'user_roles', + description: + 'User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).', + idColumn: 'user_id', + columns: [ + { name: 'user_id', type: 'string', description: 'User receiving the role. Must exist in users table.' }, + { + name: 'role', + type: 'string', + description: '"owner" has full control, always global. "admin" can manage groups and approve actions.', + enum: ['owner', 'admin'], + }, + { + name: 'agent_group_id', + type: 'string', + description: 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.', + }, + { name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' }, + { name: 'granted_at', type: 'string', description: 'Auto-set.' }, + ], + operations: { list: 'open' }, + customOperations: { + grant: { + access: 'approval', + description: 'Grant a role. Use --user, --role, and optionally --group for scoped admin.', + handler: async (args) => { + const userId = args.user as string; + const role = args.role as string; + const groupId = (args.group as string) ?? null; + const grantedBy = (args.granted_by as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!role || !['owner', 'admin'].includes(role)) throw new Error('--role must be owner or admin'); + if (role === 'owner' && groupId) throw new Error('owner role is always global (do not pass --group)'); + getDb() + .prepare( + `INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES (?, ?, ?, ?, datetime('now'))`, + ) + .run(userId, role, groupId, grantedBy); + return { user_id: userId, role, agent_group_id: groupId }; + }, + }, + revoke: { + access: 'approval', + description: 'Revoke a role. Use --user, --role, and --group if scoped.', + handler: async (args) => { + const userId = args.user as string; + const role = args.role as string; + const groupId = (args.group as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!role) throw new Error('--role is required'); + const result = getDb() + .prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS ?') + .run(userId, role, groupId); + if (result.changes === 0) throw new Error('role not found'); + return { revoked: { user_id: userId, role, agent_group_id: groupId } }; + }, + }, + }, +}); diff --git a/src/cli/resources/sessions.ts b/src/cli/resources/sessions.ts new file mode 100644 index 0000000..1a3bd24 --- /dev/null +++ b/src/cli/resources/sessions.ts @@ -0,0 +1,44 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'session', + plural: 'sessions', + table: 'sessions', + description: + 'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' }, + { + name: 'messaging_group_id', + type: 'string', + description: 'Messaging group this session serves. Null for agent-shared sessions.', + }, + { + name: 'thread_id', + type: 'string', + description: 'Thread ID. Only set for per-thread session mode.', + }, + { + name: 'agent_provider', + type: 'string', + description: 'Provider override. Null means inherit from agent group.', + }, + { + name: 'status', + type: 'string', + description: '"active" receives messages. "closed" is archived.', + enum: ['active', 'closed'], + }, + { + name: 'container_status', + type: 'string', + description: '"running" — container alive. "idle" — exited, restarts on next message. "stopped" — needs explicit wake.', + enum: ['running', 'idle', 'stopped'], + }, + { name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open' }, +}); diff --git a/src/cli/resources/users.ts b/src/cli/resources/users.ts new file mode 100644 index 0000000..5cd003e --- /dev/null +++ b/src/cli/resources/users.ts @@ -0,0 +1,33 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'user', + plural: 'users', + table: 'users', + description: + 'User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).', + idColumn: 'id', + columns: [ + { + name: 'id', + type: 'string', + description: 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.', + required: true, + }, + { + name: 'kind', + type: 'string', + description: + 'Channel type identifier (e.g. "telegram", "discord"). Used as a fallback for DM resolution when the id prefix doesn\'t match a registered adapter.', + required: true, + }, + { + name: 'display_name', + type: 'string', + description: 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' }, +}); diff --git a/src/cli/resources/wirings.ts b/src/cli/resources/wirings.ts new file mode 100644 index 0000000..f04102f --- /dev/null +++ b/src/cli/resources/wirings.ts @@ -0,0 +1,69 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'wiring', + plural: 'wirings', + table: 'messaging_group_agents', + description: + 'Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'messaging_group_id', + type: 'string', + description: 'The chat/channel to route from. References messaging_groups.id.', + required: true, + }, + { + name: 'agent_group_id', + type: 'string', + description: 'The agent that handles messages. References agent_groups.id.', + required: true, + }, + { + name: 'engage_mode', + type: 'string', + description: + 'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.', + enum: ['pattern', 'mention', 'mention-sticky'], + default: 'mention', + updatable: true, + }, + { + name: 'engage_pattern', + type: 'string', + description: + 'Regex for engage_mode=pattern. Required when mode is pattern. Use "." to match every message (always-on). Ignored for mention modes.', + updatable: true, + }, + { + name: 'sender_scope', + type: 'string', + description: '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.', + enum: ['all', 'known'], + default: 'all', + updatable: true, + }, + { + name: 'ignored_message_policy', + type: 'string', + description: + 'What happens to messages that don\'t trigger engagement. "drop" — agent never sees them. "accumulate" — stored as background context (trigger=0) so the agent has prior context when eventually triggered.', + enum: ['drop', 'accumulate'], + default: 'drop', + updatable: true, + }, + { + name: 'session_mode', + type: 'string', + description: + '"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent.', + enum: ['shared', 'per-thread', 'agent-shared'], + default: 'shared', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); From a597b42648c3a50792652fade46b7f0d4576fcdc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:40:15 +0300 Subject: [PATCH 06/13] feat(cli): add remaining resources, fix descriptions from code review New read-only resources: - destinations (agent-to-agent ACL + routing map) - user-dms (DM channel cache) - dropped-messages (audit trail for dropped messages) - approvals (in-flight approval cards) Description fixes from reading source: - messaging-groups: add denied_at column (router checks it) - sessions: fix container_status (idle is unused, stopped is auto-restarted by sweep) - wirings: add note that threaded adapters force per-thread Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/resources/approvals.ts | 36 +++++++++++++ src/cli/resources/destinations.ts | 77 +++++++++++++++++++++++++++ src/cli/resources/dropped-messages.ts | 28 ++++++++++ src/cli/resources/index.ts | 4 ++ src/cli/resources/messaging-groups.ts | 10 +++- src/cli/resources/sessions.ts | 3 +- src/cli/resources/user-dms.ts | 21 ++++++++ src/cli/resources/wirings.ts | 5 +- 8 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/cli/resources/approvals.ts create mode 100644 src/cli/resources/destinations.ts create mode 100644 src/cli/resources/dropped-messages.ts create mode 100644 src/cli/resources/user-dms.ts diff --git a/src/cli/resources/approvals.ts b/src/cli/resources/approvals.ts new file mode 100644 index 0000000..a5310a4 --- /dev/null +++ b/src/cli/resources/approvals.ts @@ -0,0 +1,36 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'approval', + plural: 'approvals', + table: 'pending_approvals', + description: + 'Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.', + idColumn: 'approval_id', + columns: [ + { name: 'approval_id', type: 'string', description: 'Unique approval identifier (also used as the card questionId).' }, + { name: 'session_id', type: 'string', description: 'Session that requested the approval. Null for OneCLI credential approvals.' }, + { name: 'request_id', type: 'string', description: 'Original request identifier (OneCLI request UUID or same as approval_id).' }, + { + name: 'action', + type: 'string', + description: 'Action type — matches the registered approval handler (e.g. install_packages, add_mcp_server, onecli_credential).', + }, + { name: 'payload', type: 'json', description: 'JSON payload carried through to the approval handler.' }, + { name: 'created_at', type: 'string', description: 'Auto-set.' }, + { name: 'agent_group_id', type: 'string', description: 'Originating agent group.' }, + { name: 'channel_type', type: 'string', description: 'Channel the approval card was delivered on.' }, + { name: 'platform_id', type: 'string', description: 'Platform chat ID the card was delivered to.' }, + { name: 'platform_message_id', type: 'string', description: 'Platform message ID of the delivered card (for editing on expiry).' }, + { name: 'expires_at', type: 'string', description: 'When this approval expires (OneCLI gateway TTL).' }, + { + name: 'status', + type: 'string', + description: 'Current status.', + enum: ['pending', 'approved', 'rejected', 'expired'], + }, + { name: 'title', type: 'string', description: 'Card title shown to the admin.' }, + { name: 'options_json', type: 'json', description: 'Card button options as JSON array.' }, + ], + operations: { list: 'open', get: 'open' }, +}); diff --git a/src/cli/resources/destinations.ts b/src/cli/resources/destinations.ts new file mode 100644 index 0000000..ea67035 --- /dev/null +++ b/src/cli/resources/destinations.ts @@ -0,0 +1,77 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'destination', + plural: 'destinations', + table: 'agent_destinations', + description: + 'Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.', + idColumn: 'agent_group_id', + columns: [ + { + name: 'agent_group_id', + type: 'string', + description: 'The agent that owns this destination. References agent_groups.id.', + }, + { + name: 'local_name', + type: 'string', + description: + 'Name the agent uses to address this target (e.g. ). Unique per agent. Lowercase, dash-separated.', + }, + { + name: 'target_type', + type: 'string', + description: '"channel" for messaging group targets, "agent" for agent-to-agent targets.', + enum: ['channel', 'agent'], + }, + { + name: 'target_id', + type: 'string', + description: 'The target\'s ID — messaging_groups.id for channels, agent_groups.id for agents.', + }, + { name: 'created_at', type: 'string', description: 'Auto-set.' }, + ], + operations: { list: 'open' }, + customOperations: { + add: { + access: 'approval', + description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.', + handler: async (args) => { + const agentGroupId = args.agent_group_id as string; + const localName = args.local_name as string; + const targetType = args.target_type as string; + const targetId = args.target_id as string; + if (!agentGroupId) throw new Error('--agent-group-id is required'); + if (!localName) throw new Error('--local-name is required'); + if (!targetType || !['channel', 'agent'].includes(targetType)) { + throw new Error('--target-type must be channel or agent'); + } + if (!targetId) throw new Error('--target-id is required'); + getDb() + .prepare( + `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES (?, ?, ?, ?, datetime('now'))`, + ) + .run(agentGroupId, localName, targetType, targetId); + return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId }; + }, + }, + remove: { + access: 'approval', + description: 'Remove a destination from an agent. Use --agent-group-id and --local-name.', + handler: async (args) => { + const agentGroupId = args.agent_group_id as string; + const localName = args.local_name as string; + if (!agentGroupId) throw new Error('--agent-group-id is required'); + if (!localName) throw new Error('--local-name is required'); + const result = getDb() + .prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?') + .run(agentGroupId, localName); + if (result.changes === 0) throw new Error('destination not found'); + return { removed: { agent_group_id: agentGroupId, local_name: localName } }; + }, + }, + }, +}); diff --git a/src/cli/resources/dropped-messages.ts b/src/cli/resources/dropped-messages.ts new file mode 100644 index 0000000..3404fc2 --- /dev/null +++ b/src/cli/resources/dropped-messages.ts @@ -0,0 +1,28 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'dropped-message', + plural: 'dropped-messages', + table: 'unregistered_senders', + description: + 'Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn\'t fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).', + idColumn: 'channel_type', + columns: [ + { name: 'channel_type', type: 'string', description: 'Channel adapter type of the dropped message.' }, + { name: 'platform_id', type: 'string', description: 'Platform chat ID where the message was dropped.' }, + { name: 'user_id', type: 'string', description: 'Sender user ID if resolved, null otherwise.' }, + { name: 'sender_name', type: 'string', description: 'Sender display name if available.' }, + { + name: 'reason', + type: 'string', + description: 'Why the message was dropped.', + enum: ['no_agent_wired', 'no_agent_engaged', 'unknown_sender_strict', 'unknown_sender_request_approval'], + }, + { name: 'messaging_group_id', type: 'string', description: 'Messaging group ID if resolved.' }, + { name: 'agent_group_id', type: 'string', description: 'Target agent group ID if resolved.' }, + { name: 'message_count', type: 'number', description: 'Number of dropped messages from this sender on this chat.' }, + { name: 'first_seen', type: 'string', description: 'First drop timestamp.' }, + { name: 'last_seen', type: 'string', description: 'Most recent drop timestamp.' }, + ], + operations: { list: 'open' }, +}); diff --git a/src/cli/resources/index.ts b/src/cli/resources/index.ts index 42155e7..816b32f 100644 --- a/src/cli/resources/index.ts +++ b/src/cli/resources/index.ts @@ -8,4 +8,8 @@ import './wirings.js'; import './users.js'; import './roles.js'; import './members.js'; +import './destinations.js'; +import './user-dms.js'; +import './dropped-messages.js'; +import './approvals.js'; import './sessions.js'; diff --git a/src/cli/resources/messaging-groups.ts b/src/cli/resources/messaging-groups.ts index edccfc0..0cda1c8 100644 --- a/src/cli/resources/messaging-groups.ts +++ b/src/cli/resources/messaging-groups.ts @@ -12,7 +12,8 @@ registerResource({ { name: 'channel_type', type: 'string', - description: 'Channel adapter type — matches the adapter registered by /add- (e.g. telegram, discord, slack, whatsapp).', + description: + 'Channel adapter type — matches the adapter registered by /add- (e.g. telegram, discord, slack, whatsapp).', required: true, }, { @@ -44,6 +45,13 @@ registerResource({ default: 'strict', updatable: true, }, + { + name: 'denied_at', + type: 'string', + description: + 'Set when the owner explicitly denies registering this channel. While set, the router drops all messages silently without re-escalating. Cleared by any explicit wiring mutation.', + updatable: true, + }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, diff --git a/src/cli/resources/sessions.ts b/src/cli/resources/sessions.ts index 1a3bd24..f60fccc 100644 --- a/src/cli/resources/sessions.ts +++ b/src/cli/resources/sessions.ts @@ -34,7 +34,8 @@ registerResource({ { name: 'container_status', type: 'string', - description: '"running" — container alive. "idle" — exited, restarts on next message. "stopped" — needs explicit wake.', + description: + '"running" — container alive and polling. "stopped" — container exited; the sweep will restart it automatically when due messages arrive. "idle" — reserved, currently unused.', enum: ['running', 'idle', 'stopped'], }, { name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' }, diff --git a/src/cli/resources/user-dms.ts b/src/cli/resources/user-dms.ts new file mode 100644 index 0000000..8b7b1cd --- /dev/null +++ b/src/cli/resources/user-dms.ts @@ -0,0 +1,21 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'user-dm', + plural: 'user-dms', + table: 'user_dms', + description: + 'User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter\'s openDM resolves it.', + idColumn: 'user_id', + columns: [ + { name: 'user_id', type: 'string', description: 'User this DM route is for.' }, + { name: 'channel_type', type: 'string', description: 'Channel adapter type.' }, + { + name: 'messaging_group_id', + type: 'string', + description: 'The messaging group used to deliver DMs to this user on this channel.', + }, + { name: 'resolved_at', type: 'string', description: 'When this DM route was last resolved.' }, + ], + operations: { list: 'open' }, +}); diff --git a/src/cli/resources/wirings.ts b/src/cli/resources/wirings.ts index f04102f..d52f8b1 100644 --- a/src/cli/resources/wirings.ts +++ b/src/cli/resources/wirings.ts @@ -40,7 +40,8 @@ registerResource({ { name: 'sender_scope', type: 'string', - description: '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.', + description: + '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.', enum: ['all', 'known'], default: 'all', updatable: true, @@ -58,7 +59,7 @@ registerResource({ name: 'session_mode', type: 'string', description: - '"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent.', + '"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent. Note: threaded adapters in group chats force per-thread regardless of this setting.', enum: ['shared', 'per-thread', 'agent-shared'], default: 'shared', updatable: true, From 8771e259a801d5c4608b79044fffaa1cdcdf8ada Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:42:33 +0300 Subject: [PATCH 07/13] style(cli): apply prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/crud.ts | 16 ++++++---------- src/cli/dispatch.ts | 6 +----- src/cli/resources/approvals.ts | 27 ++++++++++++++++++++++----- src/cli/resources/destinations.ts | 2 +- src/cli/resources/dropped-messages.ts | 2 +- src/cli/resources/groups.ts | 3 ++- src/cli/resources/roles.ts | 3 ++- src/cli/resources/user-dms.ts | 2 +- src/cli/resources/users.ts | 6 ++++-- 9 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 370b9ad..af1371f 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -99,9 +99,7 @@ function genericGet(def: ResourceDef) { return async (args: Record) => { const id = args.id as string; if (!id) throw new Error(`${def.name} id is required`); - const row = getDb() - .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) - .get(id); + const row = getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id); if (!row) throw new Error(`${def.name} not found: ${id}`); return row; }; @@ -160,7 +158,9 @@ function genericUpdate(def: ResourceDef) { } } if (Object.keys(updates).length === 0) { - throw new Error(`nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`); + throw new Error( + `nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`, + ); } const setClause = Object.keys(updates) @@ -172,9 +172,7 @@ function genericUpdate(def: ResourceDef) { if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); const cols = visibleColumns(def).join(', '); - return getDb() - .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) - .get(id); + return getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id); }; } @@ -182,9 +180,7 @@ function genericDelete(def: ResourceDef) { return async (args: Record) => { const id = args.id as string; if (!id) throw new Error(`${def.name} id is required`); - const result = getDb() - .prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`) - .run(id); + const result = getDb().prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`).run(id); if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); return { deleted: id }; }; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index a9943c4..f4c8987 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -18,11 +18,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise.', + description: + 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', updatable: true, default: null, }, diff --git a/src/cli/resources/roles.ts b/src/cli/resources/roles.ts index 4e0a20b..9d51815 100644 --- a/src/cli/resources/roles.ts +++ b/src/cli/resources/roles.ts @@ -19,7 +19,8 @@ registerResource({ { name: 'agent_group_id', type: 'string', - description: 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.', + description: + 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.', }, { name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' }, { name: 'granted_at', type: 'string', description: 'Auto-set.' }, diff --git a/src/cli/resources/user-dms.ts b/src/cli/resources/user-dms.ts index 8b7b1cd..50b2763 100644 --- a/src/cli/resources/user-dms.ts +++ b/src/cli/resources/user-dms.ts @@ -5,7 +5,7 @@ registerResource({ plural: 'user-dms', table: 'user_dms', description: - 'User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter\'s openDM resolves it.', + "User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.", idColumn: 'user_id', columns: [ { name: 'user_id', type: 'string', description: 'User this DM route is for.' }, diff --git a/src/cli/resources/users.ts b/src/cli/resources/users.ts index 5cd003e..0c4fd56 100644 --- a/src/cli/resources/users.ts +++ b/src/cli/resources/users.ts @@ -11,7 +11,8 @@ registerResource({ { name: 'id', type: 'string', - description: 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.', + description: + 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.', required: true, }, { @@ -24,7 +25,8 @@ registerResource({ { name: 'display_name', type: 'string', - description: 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.', + description: + 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.', updatable: true, }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, From 9090c33e7e8d3c8e33741f54f87b8c5b71ff1ef1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:48:57 +0300 Subject: [PATCH 08/13] docs(cli): add agent instructions for nc CLI Auto-discovered by composeGroupClaudeMd() as module-cli.md fragment, included in every agent group's composed CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mcp-tools/cli.instructions.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 container/agent-runner/src/mcp-tools/cli.instructions.md diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md new file mode 100644 index 0000000..5bec167 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -0,0 +1,60 @@ +## Admin CLI (`nc`) + +The `nc` command is available at `/usr/local/bin/nc`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. + +### Usage + +``` +nc [] [--flags] +nc help +nc help +``` + +### Resources + +| Resource | Verbs | What it is | +|----------|-------|------------| +| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | +| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | +| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | +| users | list, get, create, update | Platform identities (`:`) | +| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) | +| members | list, add, remove | Unprivileged access gate for an agent group | +| destinations | list, add, remove | Where an agent group can send messages | +| sessions | list, get | Active sessions (read-only) | +| user-dms | list | Cold-DM cache (read-only) | +| dropped-messages | list | Messages from unregistered senders (read-only) | +| approvals | list, get | Pending approval requests (read-only) | + +### When to use + +- **Looking up your own config** — `nc groups get ` to see your agent group settings. +- **Finding who you're wired to** — `nc wirings list` to see which messaging groups route to which agent groups. +- **Checking user roles** — `nc roles list` to see who is an owner/admin. +- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `nc` rather than guessing. + +### Access rules + +Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it. + +### Examples + +```bash +# List all agent groups +nc groups list + +# Get details for a specific group +nc groups get abc123 + +# See field definitions for a resource +nc wirings help + +# List all wirings for a specific messaging group +nc wirings list --messaging-group-id mg_xyz +``` + +### Tips + +- Use `nc help` to see all available fields, types, enums, and which fields are required or updatable. +- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. +- For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. From 0855369b79b9a516afb7cb79edfb720ca0bd35e7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 15:56:09 +0300 Subject: [PATCH 09/13] refactor(cli): rename nc to ncl Rename the CLI binary, socket path, container wrapper, error prefixes, and all references from `nc` to `ncl`. Add ~/.local/bin symlink during setup and pnpm script alias. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/{nc => ncl} | 8 ++--- container/Dockerfile | 8 ++--- .../agent-runner/src/cli/{nc.ts => ncl.ts} | 10 +++--- package.json | 4 +++ scripts/chat.ts | 4 +-- setup/service.ts | 35 +++++++++++++++++++ src/cli/client.ts | 34 +++++++++--------- src/cli/commands/help.ts | 6 ++-- src/cli/crud.ts | 2 +- src/cli/format.ts | 2 +- src/cli/registry.ts | 2 +- src/cli/socket-client.ts | 4 +-- src/cli/socket-server.ts | 14 ++++---- src/cli/transport.ts | 2 +- src/index.ts | 4 +-- 15 files changed, 89 insertions(+), 50 deletions(-) rename bin/{nc => ncl} (85%) rename container/agent-runner/src/cli/{nc.ts => ncl.ts} (95%) diff --git a/bin/nc b/bin/ncl similarity index 85% rename from bin/nc rename to bin/ncl index caceb42..27cc09a 100755 --- a/bin/nc +++ b/bin/ncl @@ -1,15 +1,15 @@ #!/usr/bin/env bash # -# nc — NanoClaw CLI launcher. +# ncl — 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` +# via tsx. Symlink this file into a directory on your PATH (or alias `ncl` # to its full path) to invoke from anywhere: # -# ln -s "$(pwd)/bin/nc" /usr/local/bin/nc +# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl # # or -# alias nc="$(pwd)/bin/nc" +# alias ncl="$(pwd)/bin/ncl" set -euo pipefail diff --git a/container/Dockerfile b/container/Dockerfile index 1dd2f88..89f834a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -110,10 +110,10 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -# ---- nc CLI wrapper ---------------------------------------------------------- -# Actual script lives in the mounted source at /app/src/cli/nc.ts. -RUN printf '#!/bin/sh\nexec bun /app/src/cli/nc.ts "$@"\n' > /usr/local/bin/nc && \ - chmod +x /usr/local/bin/nc +# ---- ncl CLI wrapper ---------------------------------------------------------- +# Actual script lives in the mounted source at /app/src/cli/ncl.ts. +RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \ + chmod +x /usr/local/bin/ncl # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/ncl.ts similarity index 95% rename from container/agent-runner/src/cli/nc.ts rename to container/agent-runner/src/cli/ncl.ts index cc3883e..83bd666 100644 --- a/container/agent-runner/src/cli/nc.ts +++ b/container/agent-runner/src/cli/ncl.ts @@ -1,8 +1,8 @@ #!/usr/bin/env bun /** - * nc — NanoClaw CLI client (container edition). + * ncl — NanoClaw CLI client (container edition). * - * Same interface as the host-side `bin/nc`. Detects that it's inside a + * Same interface as the host-side `bin/ncl`. Detects that it's inside a * container (the session DBs exist at /workspace/) and uses a DB transport * instead of the Unix socket transport. * @@ -162,7 +162,7 @@ function parseArgv(argv: string[]): { } if (positional.length === 0) { - process.stderr.write('nc: missing command\n'); + process.stderr.write('ncl: missing command\n'); printUsage(); process.exit(2); } @@ -179,7 +179,7 @@ function parseArgv(argv: string[]): { function printUsage(): void { process.stdout.write( - ['Usage: nc [--key value ...] [--json]', '', 'Run `nc help` to list available commands.', ''].join('\n'), + ['Usage: ncl [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'), ); } @@ -243,7 +243,7 @@ writeRequest(req); const resp = pollResponse(requestId, 30_000); if (!resp) { - process.stderr.write('nc: command timed out after 30s\n'); + process.stderr.write('ncl: command timed out after 30s\n'); process.exit(2); } diff --git a/package.json b/package.json index 77afaaf..6bddd32 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "packageManager": "pnpm@10.33.0", "main": "dist/index.js", + "bin": { + "ncl": "bin/ncl" + }, "scripts": { "build": "tsc", "start": "node dist/index.js", @@ -16,6 +19,7 @@ "prepare": "husky", "setup": "tsx setup/index.ts", "setup:auto": "tsx setup/auto.ts", + "ncl": "tsx src/cli/client.ts", "chat": "tsx scripts/chat.ts", "auth": "tsx src/whatsapp-auth.ts", "lint": "eslint src/", diff --git a/scripts/chat.ts b/scripts/chat.ts index 20194fb..e32fcee 100644 --- a/scripts/chat.ts +++ b/scripts/chat.ts @@ -1,5 +1,5 @@ /** - * nc — chat with your NanoClaw agent from the terminal. + * ncl — chat with your NanoClaw agent from the terminal. * * Usage: * pnpm run chat @@ -36,7 +36,7 @@ function main(): void { const e = err as NodeJS.ErrnoException; if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') { console.error(`NanoClaw daemon not reachable at ${socketPath()}.`); - console.error('Start the service (launchctl/systemd) before running nc.'); + console.error('Start the service (launchctl/systemd) before running ncl.'); } else { console.error('CLI socket error:', err); } diff --git a/setup/service.ts b/setup/service.ts index 777c0c5..a866a92 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -82,6 +82,41 @@ export async function run(_args: string[]): Promise { }); process.exit(1); } + + installCliSymlink(projectRoot, homeDir); +} + +/** + * Symlink bin/ncl into ~/.local/bin so `ncl` is available from anywhere. + * Idempotent — overwrites an existing symlink but won't clobber a real file. + */ +function installCliSymlink(projectRoot: string, homeDir: string): void { + const source = path.join(projectRoot, 'bin', 'ncl'); + const targetDir = path.join(homeDir, '.local', 'bin'); + const target = path.join(targetDir, 'ncl'); + + try { + fs.mkdirSync(targetDir, { recursive: true }); + + // Remove existing symlink (but not a real file) + try { + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink()) { + fs.unlinkSync(target); + } else { + log.warn('~/.local/bin/ncl exists and is not a symlink — skipping', { target }); + return; + } + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') throw err; + } + + fs.symlinkSync(source, target); + log.info('Installed ncl CLI symlink', { target, source }); + } catch (err) { + log.warn('Could not install ncl CLI symlink (non-fatal)', { err }); + } } function setupLaunchd( diff --git a/src/cli/client.ts b/src/cli/client.ts index 36197ad..98527ed 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -1,19 +1,19 @@ /** - * `nc` binary entry point. + * `ncl` 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 [target] [--key value ...] [--json] + * ncl [target] [--key value ...] [--json] * * Examples: - * nc groups list - * nc groups get abc123 - * nc groups create --name foo --folder bar - * nc groups update abc123 --name baz - * nc help - * nc groups help + * ncl groups list + * ncl groups get abc123 + * ncl groups create --name foo --folder bar + * ncl groups update abc123 --name baz + * ncl help + * ncl groups help */ import { randomUUID } from 'crypto'; @@ -80,14 +80,14 @@ function parseArgv(argv: string[]): { } if (positional.length === 0) { - process.stderr.write('nc: missing command\n'); + process.stderr.write('ncl: missing command\n'); printUsage(); process.exit(2); } - // Single word: `nc help` - // Two words: `nc groups list`, `nc groups help` - // Three words: `nc groups get abc123` + // Single word: `ncl help` + // Two words: `ncl groups list`, `ncl groups help` + // Three words: `ncl groups get abc123` let command: string; if (positional.length === 1) { command = positional[0]; @@ -106,9 +106,9 @@ function parseArgv(argv: string[]): { function printUsage(): void { process.stdout.write( [ - 'Usage: nc [target] [--key value ...] [--json]', + 'Usage: ncl [target] [--key value ...] [--json]', '', - 'Run `nc help` to list available resources and commands.', + 'Run `ncl help` to list available resources and commands.', '', ].join('\n'), ); @@ -118,7 +118,7 @@ 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}).`, + `ncl: 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`, @@ -126,10 +126,10 @@ function formatTransportError(e: unknown): string { ``, ].join('\n'); } - return `nc: transport error: ${msg}\n`; + return `ncl: transport error: ${msg}\n`; } main().catch((err) => { - process.stderr.write(`nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); + process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(2); }); diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index 9219b70..d50eaef 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -1,8 +1,8 @@ /** * Built-in help command. Introspects the resource and command registries. * - * nc help — list all resources and commands - * nc groups help — show group resource details (verbs, columns, enums) + * ncl help — list all resources and commands + * ncl groups help — show group resource details (verbs, columns, enums) */ import { getResource, getResources } from '../crud.js'; import { listCommands, register } from '../registry.js'; @@ -41,7 +41,7 @@ register({ } lines.push(''); - lines.push('Run `nc help` for detailed field information.'); + lines.push('Run `ncl help` for detailed field information.'); return lines.join('\n'); }, }); diff --git a/src/cli/crud.ts b/src/cli/crud.ts index af1371f..98c9989 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -3,7 +3,7 @@ * * Takes a declarative resource definition (table, columns, access levels) * and auto-registers list/get/create/update/delete commands in the CLI - * registry. Column metadata doubles as documentation — `nc help` + * registry. Column metadata doubles as documentation — `ncl help` * is generated from the same definitions. */ import { randomUUID } from 'crypto'; diff --git a/src/cli/format.ts b/src/cli/format.ts index 5ce67c0..9b54599 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,5 +1,5 @@ /** - * Output formatting for the `nc` binary. Two modes: + * Output formatting for the `ncl` 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. diff --git a/src/cli/registry.ts b/src/cli/registry.ts index bd75782..a60e74a 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -1,5 +1,5 @@ /** - * Command registry — single source of truth for what `nc` can do. + * Command registry — single source of truth for what `ncl` can do. * * Each command file under `commands/` calls `register()` at top level, * and `commands/index.ts` imports them all for side effects so the diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts index c94a4dc..4c80c5d 100644 --- a/src/cli/socket-client.ts +++ b/src/cli/socket-client.ts @@ -1,5 +1,5 @@ /** - * SocketTransport — client side. Used by the `nc` binary when running on + * SocketTransport — client side. Used by the `ncl` 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 @@ -12,7 +12,7 @@ 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 const DEFAULT_SOCKET_PATH = path.join(DATA_DIR, 'ncl.sock'); export class SocketTransport implements Transport { constructor(private readonly socketPath: string = DEFAULT_SOCKET_PATH) {} diff --git a/src/cli/socket-server.ts b/src/cli/socket-server.ts index 7ed2683..9027848 100644 --- a/src/cli/socket-server.ts +++ b/src/cli/socket-server.ts @@ -3,7 +3,7 @@ * 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 + * Lives at data/ncl.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. */ @@ -25,7 +25,7 @@ export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): } 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 }); + log.warn('Failed to unlink stale ncl socket (will try to bind anyway)', { socketPath, err }); } } @@ -37,9 +37,9 @@ export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): try { fs.chmodSync(socketPath, 0o600); } catch (err) { - log.warn('Failed to chmod nc socket (continuing)', { socketPath, err }); + log.warn('Failed to chmod ncl socket (continuing)', { socketPath, err }); } - log.info('nc CLI server listening', { socketPath }); + log.info('ncl CLI server listening', { socketPath }); resolve(); }); }); @@ -65,7 +65,7 @@ function handleConnection(conn: net.Socket): void { } }); conn.on('error', (err) => { - log.warn('nc CLI server connection error', { err }); + log.warn('ncl CLI server connection error', { err }); }); } @@ -87,7 +87,7 @@ async function handleFrame(conn: net.Socket, line: string): Promise { return; } - // Host caller — connecting to data/nc.sock requires file-system access + // Host caller — connecting to data/ncl.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' }; @@ -100,7 +100,7 @@ function write(conn: net.Socket, frame: ResponseFrame): void { conn.write(JSON.stringify(frame) + '\n'); conn.end(); } catch (err) { - log.warn('Failed to write nc CLI response', { err }); + log.warn('Failed to write ncl CLI response', { err }); } } diff --git a/src/cli/transport.ts b/src/cli/transport.ts index b263102..14285ec 100644 --- a/src/cli/transport.ts +++ b/src/cli/transport.ts @@ -1,5 +1,5 @@ /** - * Client-side transport interface. The `nc` binary picks one of these and + * Client-side transport interface. The `ncl` 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). */ diff --git a/src/index.ts b/src/index.ts index 3d39dd8..f16992a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,7 +53,7 @@ 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 +// CLI command barrel — populates the `ncl` registry before the CLI server // accepts connections. import './cli/commands/index.js'; import './cli/delivery-action.js'; @@ -169,7 +169,7 @@ async function main(): Promise { startHostSweep(); log.info('Host sweep started'); - // 7. Start the `nc` CLI socket server (data/nc.sock). + // 7. Start the `ncl` CLI socket server (data/ncl.sock). await startCliServer(); log.info('NanoClaw running'); From 046b99c7457bf47e03eba02fe9333c3fe609ed8f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 16:31:30 +0300 Subject: [PATCH 10/13] feat(cli): wire approval flow for agent CLI commands When a container agent calls an approval-gated ncl command, dispatch now sends an approval card to an admin instead of returning a stub error. On approve, the handler re-dispatches the original command and notifies the agent with the result. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/dispatch.ts | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index f4c8987..7b247eb 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -6,6 +6,9 @@ * Approval gating for risky calls from the container is the only branch * that differs by caller. Host callers and `open` commands run inline. */ +import { getAgentGroup } from '../db/agent-groups.js'; +import { getSession } from '../db/sessions.js'; +import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js'; import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { lookup } from './registry.js'; @@ -15,10 +18,28 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise `--${k} ${v}`) + .join(' '); + + await requestApproval({ + session, + agentName, + action: 'cli_command', + payload: { frame: { id: req.id, command: req.command, args: req.args } }, + title: `CLI: ${req.command}`, + question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``, + }); + + return err(req.id, 'approval-pending', 'Approval request sent to admin. You will be notified of the result.'); } let parsed: unknown; @@ -36,6 +57,18 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise { + const frame = payload.frame as RequestFrame; + const response = await dispatch(frame, { caller: 'host' }); + + if (response.ok) { + const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); + notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`); + } else { + notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`); + } +}); + function err(id: string, code: ErrorCode, message: string): ResponseFrame { return { id, ok: false, error: { code, message } }; } From ed571d1f66719a1f971bf2a324309649f5b6e1e0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 20:45:18 +0300 Subject: [PATCH 11/13] =?UTF-8?q?docs(cli):=20add=20write=20examples,=20ap?= =?UTF-8?q?proval=20flow,=20and=20nc=E2=86=92ncl=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add approval flow section explaining the request→notify→result mechanics - Add write command examples (groups create, roles grant, members add, etc.) - Rename stale `nc` references to `ncl` in container instructions - Add CLI reference section to host CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 26 +++++++++ .../src/mcp-tools/cli.instructions.md | 57 ++++++++++++------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92824fb..e941490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,32 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | | `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). | +## Admin CLI (`ncl`) + +`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`). + +``` +ncl [] [--flags] +ncl help +ncl help +``` + +| Resource | Verbs | What it is | +|----------|-------|------------| +| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | +| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | +| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | +| users | list, get, create, update | Platform identities (`:`) | +| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) | +| members | list, add, remove | Unprivileged access gate for an agent group | +| destinations | list, add, remove | Where an agent group can send messages | +| sessions | list, get | Active sessions (read-only) | +| user-dms | list | Cold-DM cache (read-only) | +| dropped-messages | list | Messages from unregistered senders (read-only) | +| approvals | list, get | Pending approval requests (read-only) | + +Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions). + ## Channels and Providers (skill-installed) Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills: diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index 5bec167..f9965f5 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -1,13 +1,13 @@ -## Admin CLI (`nc`) +## Admin CLI (`ncl`) -The `nc` command is available at `/usr/local/bin/nc`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. +The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. ### Usage ``` -nc [] [--flags] -nc help -nc help +ncl [] [--flags] +ncl help +ncl help ``` ### Resources @@ -28,33 +28,50 @@ nc help ### When to use -- **Looking up your own config** — `nc groups get ` to see your agent group settings. -- **Finding who you're wired to** — `nc wirings list` to see which messaging groups route to which agent groups. -- **Checking user roles** — `nc roles list` to see who is an owner/admin. -- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `nc` rather than guessing. +- **Looking up your own config** — `ncl groups get ` to see your agent group settings. +- **Finding who you're wired to** — `ncl wirings list` to see which messaging groups route to which agent groups. +- **Checking user roles** — `ncl roles list` to see who is an owner/admin. +- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `ncl` rather than guessing. ### Access rules Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it. +### Approval flow + +Write commands (create, update, delete, grant, revoke, add, remove) require admin approval. Here's what happens: + +1. You run the command (e.g. `ncl groups create --name "Research" --folder research`). +2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet. +3. An admin or owner gets a notification (on the same channel when possible) showing exactly what you requested, with approve/reject options. +4. Once the admin responds: + - **Approved:** the command executes and the result is delivered back to you as a system message in this conversation. + - **Rejected:** you get a system message saying the request was rejected. + +You don't need to poll or retry — the result arrives automatically. + ### Examples ```bash -# List all agent groups -nc groups list +# Read commands (no approval needed) +ncl groups list +ncl groups get abc123 +ncl wirings list --messaging-group-id mg_xyz +ncl roles list +ncl wirings help -# Get details for a specific group -nc groups get abc123 - -# See field definitions for a resource -nc wirings help - -# List all wirings for a specific messaging group -nc wirings list --messaging-group-id mg_xyz +# Write commands (approval required) +ncl groups create --name "Research" --folder research +ncl groups update abc123 --name "Research v2" +ncl roles grant --user telegram:jane --role admin +ncl roles grant --user discord:bob --role admin --group abc123 +ncl members add --user-id telegram:jane --agent-group-id abc123 +ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz ``` ### Tips -- Use `nc help` to see all available fields, types, enums, and which fields are required or updatable. +- Use `ncl help` to see all available fields, types, enums, and which fields are required or updatable. - Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. - For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. +- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result. From 6caad0757ad8407b2005fabe317aa0b159dabb7c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 21:02:23 +0300 Subject: [PATCH 12/13] fix(cli): add list filtering/pagination, fix double-close in container ncl - genericList now accepts column filters (--flag value) and LIMIT (default 200) - Remove early inDb.close() in container pollResponse to avoid double-close - Document filtering and --limit in cli.instructions.md Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/cli/ncl.ts | 4 +--- .../src/mcp-tools/cli.instructions.md | 1 + src/cli/crud.ts | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts index 83bd666..d86c601 100644 --- a/container/agent-runner/src/cli/ncl.ts +++ b/container/agent-runner/src/cli/ncl.ts @@ -102,8 +102,6 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul .get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null; if (row) { - inDb.close(); - // Mark as completed via processing_ack so agent-runner skips it const outDb = new Database(OUTBOUND_DB); outDb.exec('PRAGMA journal_mode = DELETE'); @@ -119,7 +117,7 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul return parsed.frame as ResponseFrame; } } finally { - try { inDb.close(); } catch {} + inDb.close(); } Bun.sleepSync(500); diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index f9965f5..9dee60f 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -73,5 +73,6 @@ ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz - Use `ncl help` to see all available fields, types, enums, and which fields are required or updatable. - Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. +- `list` supports filtering by any non-auto column (e.g. `ncl wirings list --messaging-group-id mg_xyz`). Default limit is 200 rows; override with `--limit N`. - For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. - Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result. diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 98c9989..7721f67 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -89,8 +89,21 @@ function visibleColumns(def: ResourceDef): string[] { function genericList(def: ResourceDef) { const cols = visibleColumns(def).join(', '); - return async () => { - return getDb().prepare(`SELECT ${cols} FROM ${def.table}`).all(); + const filterableNames = new Set(def.columns.filter((c) => !c.generated).map((c) => c.name)); + return async (args: Record) => { + const limit = args.limit !== undefined ? Math.max(1, Number(args.limit)) : 200; + const filters: string[] = []; + const params: unknown[] = []; + for (const [k, v] of Object.entries(args)) { + if (k === 'id' || k === 'limit') continue; + if (filterableNames.has(k)) { + filters.push(`${k} = ?`); + params.push(v); + } + } + const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : ''; + params.push(limit); + return getDb().prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`).all(...params); }; } @@ -211,7 +224,7 @@ export function registerResource(def: ResourceDef): void { description: `List all ${def.plural}.`, access: def.operations.list, resource: def.plural, - parseArgs: () => ({}), + parseArgs: (raw) => normalizeArgs(raw), handler: genericList(def), }); } From 01eac7b225c87f956813415125cbab937fed1835 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 21:04:07 +0300 Subject: [PATCH 13/13] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/crud.ts | 4 +++- src/delivery.test.ts | 17 +++++++++++++---- src/host-core.test.ts | 8 ++++++-- src/modules/agent-to-agent/agent-route.test.ts | 14 ++++++++++++-- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 7721f67..928aeed 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -103,7 +103,9 @@ function genericList(def: ResourceDef) { } const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : ''; params.push(limit); - return getDb().prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`).all(...params); + return getDb() + .prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`) + .all(...params); }; } diff --git a/src/delivery.test.ts b/src/delivery.test.ts index 5d23536..aadfde8 100644 --- a/src/delivery.test.ts +++ b/src/delivery.test.ts @@ -26,7 +26,14 @@ vi.mock('./config.js', async () => { const TEST_DIR = '/tmp/nanoclaw-test-delivery'; -import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.js'; +import { + initTestDb, + closeDb, + runMigrations, + createAgentGroup, + createMessagingGroup, + createMessagingGroupAgent, +} from './db/index.js'; import { getDeliveredIds } from './db/session-db.js'; import { resolveSession, outboundDbPath, openInboundDb } from './session-manager.js'; import { deliverSessionMessages, setDeliveryAdapter } from './delivery.js'; @@ -233,10 +240,12 @@ describe('deliverSessionMessages — permission check', () => { // Insert an outbound message targeting mg-2 (discord) — not the origin chat const outDb = new Database(outboundDbPath('ag-1', session.id)); - outDb.prepare( - `INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content) + outDb + .prepare( + `INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content) VALUES (?, datetime('now'), 'chat', 'discord:456', 'discord', ?)`, - ).run('out-unauth', JSON.stringify({ text: 'sneaky' })); + ) + .run('out-unauth', JSON.stringify({ text: 'sneaky' })); outDb.close(); const calls: string[] = []; diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 1225b76..e6b9153 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -838,7 +838,6 @@ describe('agent-shared session resolution', () => { const { session } = resolveSession('ag-1', null, null, 'agent-shared'); expect(session.messaging_group_id).toBeNull(); }); - }); describe('agent-to-agent routing', () => { @@ -885,7 +884,12 @@ describe('agent-to-agent routing', () => { const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); await routeAgentMessage( - { id: 'out-a2a-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research this' }), in_reply_to: null }, + { + id: 'out-a2a-1', + platform_id: 'ag-researcher', + content: JSON.stringify({ text: 'research this' }), + in_reply_to: null, + }, paSlackSession, ); diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts index fca0d4b..b0bc66e 100644 --- a/src/modules/agent-to-agent/agent-route.test.ts +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -328,7 +328,12 @@ describe('routeAgentMessage return-path', () => { // B replies to A, but in_reply_to references the C-originated row. // Guard rejects (SC belongs to C, not A) → falls through to newest of A. await routeAgentMessage( - { id: 'msg-reply-tamper', platform_id: A, content: JSON.stringify({ text: 'misdirected' }), in_reply_to: cInboundId }, + { + id: 'msg-reply-tamper', + platform_id: A, + content: JSON.stringify({ text: 'misdirected' }), + in_reply_to: cInboundId, + }, SB, ); @@ -353,7 +358,12 @@ describe('routeAgentMessage return-path', () => { // B replies to A with in_reply_to pointing to the channel message. // source_session_id is null → peer-affinity finds nothing → newest of A. await routeAgentMessage( - { id: 'msg-reply-channel', platform_id: A, content: JSON.stringify({ text: 'response' }), in_reply_to: 'channel-msg-1' }, + { + id: 'msg-reply-channel', + platform_id: A, + content: JSON.stringify({ text: 'response' }), + in_reply_to: 'channel-msg-1', + }, SB, );