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/bin/ncl b/bin/ncl new file mode 100755 index 0000000..27cc09a --- /dev/null +++ b/bin/ncl @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# 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 `ncl` +# to its full path) to invoke from anywhere: +# +# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl +# # or +# alias ncl="$(pwd)/bin/ncl" + +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/container/Dockerfile b/container/Dockerfile index bc7a6be..89f834a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -110,6 +110,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}" +# ---- 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 RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts new file mode 100644 index 0000000..d86c601 --- /dev/null +++ b/container/agent-runner/src/cli/ncl.ts @@ -0,0 +1,257 @@ +#!/usr/bin/env bun +/** + * ncl — NanoClaw CLI client (container edition). + * + * 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. + * + * 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) { + // 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 { + inDb.close(); + } + + 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('ncl: missing command\n'); + printUsage(); + process.exit(2); + } + + 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 }; +} + +function printUsage(): void { + process.stdout.write( + ['Usage: ncl [--key value ...] [--json]', '', 'Run `ncl 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('ncl: 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/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md new file mode 100644 index 0000000..9dee60f --- /dev/null +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -0,0 +1,78 @@ +## Admin CLI (`ncl`) + +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 + +``` +ncl [] [--flags] +ncl help +ncl 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** — `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 +# 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 + +# 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 `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/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 new file mode 100644 index 0000000..98527ed --- /dev/null +++ b/src/cli/client.ts @@ -0,0 +1,135 @@ +/** + * `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: + * ncl [target] [--key value ...] [--json] + * + * Examples: + * 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'; + +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 { + 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('ncl: missing command\n'); + printUsage(); + process.exit(2); + } + + // 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]; + } else { + command = `${positional[0]}-${positional[1]}`; + } + + // Third positional is the target ID + if (positional.length >= 3) { + args.id = positional[2]; + } + + return { command, args, json }; +} + +function printUsage(): void { + process.stdout.write( + [ + 'Usage: ncl [target] [--key value ...] [--json]', + '', + 'Run `ncl help` to list available resources and commands.', + '', + ].join('\n'), + ); +} + +function formatTransportError(e: unknown): string { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) { + return [ + `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`, + ` Linux: systemctl --user restart nanoclaw`, + ``, + ].join('\n'); + } + return `ncl: transport error: ${msg}\n`; +} + +main().catch((err) => { + 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 new file mode 100644 index 0000000..d50eaef --- /dev/null +++ b/src/cli/commands/help.ts @@ -0,0 +1,106 @@ +/** + * Built-in help command. Introspects the resource and command registries. + * + * 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'; + +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 `ncl 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 new file mode 100644 index 0000000..5b05345 --- /dev/null +++ b/src/cli/commands/index.ts @@ -0,0 +1,10 @@ +/** + * 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/crud.ts b/src/cli/crud.ts new file mode 100644 index 0000000..928aeed --- /dev/null +++ b/src/cli/crud.ts @@ -0,0 +1,291 @@ +/** + * 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 — `ncl 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(', '); + 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); + }; +} + +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: (raw) => normalizeArgs(raw), + 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/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/cli/dispatch.ts b/src/cli/dispatch.ts new file mode 100644 index 0000000..7b247eb --- /dev/null +++ b/src/cli/dispatch.ts @@ -0,0 +1,78 @@ +/** + * Transport-agnostic dispatcher. Both the socket server (host caller) and + * 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 `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'; + +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}"`); + } + + if (ctx.caller !== 'host' && cmd.access === 'approval') { + const session = getSession(ctx.sessionId); + if (!session) { + return err(req.id, 'handler-error', 'Session not found.'); + } + const agentGroup = getAgentGroup(ctx.agentGroupId); + const agentName = agentGroup?.name ?? ctx.agentGroupId; + + const argSummary = Object.entries(req.args) + .map(([k, v]) => `--${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; + 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)); + } +} + +registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => { + 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 } }; +} + +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..9b54599 --- /dev/null +++ b/src/cli/format.ts @@ -0,0 +1,52 @@ +/** + * 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. + * + * 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..a60e74a --- /dev/null +++ b/src/cli/registry.ts @@ -0,0 +1,38 @@ +/** + * 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 + * registry is populated before the host's CLI server accepts connections. + */ +import type { CallerContext } from './frame.js'; + +export type Access = 'open' | 'approval' | 'hidden'; + +export type CommandDef = { + name: string; + description: string; + 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; +}; + +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/resources/approvals.ts b/src/cli/resources/approvals.ts new file mode 100644 index 0000000..c67f4bc --- /dev/null +++ b/src/cli/resources/approvals.ts @@ -0,0 +1,53 @@ +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..4a56029 --- /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..88bdf26 --- /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/groups.ts b/src/cli/resources/groups.ts new file mode 100644 index 0000000..e334fc1 --- /dev/null +++ b/src/cli/resources/groups.ts @@ -0,0 +1,37 @@ +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..816b32f --- /dev/null +++ b/src/cli/resources/index.ts @@ -0,0 +1,15 @@ +/** + * 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 './destinations.js'; +import './user-dms.js'; +import './dropped-messages.js'; +import './approvals.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..0cda1c8 --- /dev/null +++ b/src/cli/resources/messaging-groups.ts @@ -0,0 +1,58 @@ +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: '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/roles.ts b/src/cli/resources/roles.ts new file mode 100644 index 0000000..9d51815 --- /dev/null +++ b/src/cli/resources/roles.ts @@ -0,0 +1,67 @@ +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..f60fccc --- /dev/null +++ b/src/cli/resources/sessions.ts @@ -0,0 +1,45 @@ +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 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.' }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open' }, +}); diff --git a/src/cli/resources/user-dms.ts b/src/cli/resources/user-dms.ts new file mode 100644 index 0000000..50b2763 --- /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/users.ts b/src/cli/resources/users.ts new file mode 100644 index 0000000..0c4fd56 --- /dev/null +++ b/src/cli/resources/users.ts @@ -0,0 +1,35 @@ +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..d52f8b1 --- /dev/null +++ b/src/cli/resources/wirings.ts @@ -0,0 +1,70 @@ +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. Note: threaded adapters in group chats force per-thread regardless of this setting.', + 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' }, +}); diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts new file mode 100644 index 0000000..4c80c5d --- /dev/null +++ b/src/cli/socket-client.ts @@ -0,0 +1,63 @@ +/** + * 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 + * 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, 'ncl.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..9027848 --- /dev/null +++ b/src/cli/socket-server.ts @@ -0,0 +1,111 @@ +/** + * 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/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. + */ +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 ncl 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 ncl socket (continuing)', { socketPath, err }); + } + log.info('ncl 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('ncl 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/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' }; + 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 ncl 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..14285ec --- /dev/null +++ b/src/cli/transport.ts @@ -0,0 +1,10 @@ +/** + * 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). + */ +import type { RequestFrame, ResponseFrame } from './frame.js'; + +export interface Transport { + sendFrame(req: RequestFrame): Promise; +} 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/index.ts b/src/index.ts index 9ded3d6..f16992a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,12 @@ import './channels/index.js'; // append registry-based modules. Imported for side effects (registrations). import './modules/index.js'; +// CLI command barrel — populates the `ncl` 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'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; @@ -163,6 +169,9 @@ async function main(): Promise { startHostSweep(); log.info('Host sweep started'); + // 7. Start the `ncl` CLI socket server (data/ncl.sock). + await startCliServer(); + log.info('NanoClaw running'); } @@ -178,6 +187,7 @@ async function shutdown(signal: string): Promise { } stopDeliveryPolls(); stopHostSweep(); + await stopCliServer(); try { await teardownChannelAdapters(); } finally { 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, );