Merge pull request #2350 from qwibitai/ncl

feat(cli): add ncl admin CLI
This commit is contained in:
gavrielc
2026-05-08 21:05:29 +03:00
committed by GitHub
36 changed files with 2043 additions and 10 deletions
+26
View File
@@ -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 <resource> <verb> [<id>] [--flags]
ncl <resource> 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 (`<channel>:<handle>`) |
| 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:
Executable
+27
View File
@@ -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 "$@"
+5
View File
@@ -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
+257
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
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 <command> [--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<string, unknown>).every((v) => typeof v !== 'object' || v === null),
);
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
const keys = Object.keys(data[0] as Record<string, unknown>);
const widths = keys.map((k) =>
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[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<string, unknown>)[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);
}
@@ -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 <resource> <verb> [<id>] [--flags]
ncl <resource> 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 (`<channel>:<handle>`) |
| 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 <your-group-id>` 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 <resource> 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.
+4
View File
@@ -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/",
+2 -2
View File
@@ -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 <message...>
@@ -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);
}
+35
View File
@@ -82,6 +82,41 @@ export async function run(_args: string[]): Promise<void> {
});
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(
+135
View File
@@ -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 <resource> <verb> [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<void> {
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<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
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 <resource> <verb> [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);
});
+106
View File
@@ -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 <resource> 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 `<plural>-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 <id> [open]`);
if (res.operations.create) verbs.push(`create [approval]`);
if (res.operations.update) verbs.push(`update <id> [approval]`);
if (res.operations.delete) verbs.push(`delete <id> [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
}
}
}
+10
View File
@@ -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();
+291
View File
@@ -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 <resource> 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<string, unknown>, ctx: CallerContext) => Promise<unknown>;
}
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<string, CustomOperation>;
}
// ---------------------------------------------------------------------------
// Resource registry (for help introspection)
// ---------------------------------------------------------------------------
const resources = new Map<string, ResourceDef>();
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
const values: Record<string, unknown> = {};
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<string, unknown>) => {
const id = args.id as string;
if (!id) throw new Error(`${def.name} id is required`);
const updates: Record<string, unknown> = {};
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<string, unknown>) => {
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown>, ctx),
});
}
}
}
+59
View File
@@ -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<string, unknown>) ?? {};
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 });
});
+78
View File
@@ -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<ResponseFrame> {
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);
}
+52
View File
@@ -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<string, unknown>[]);
}
return JSON.stringify(data, null, 2);
}
function isFlatRecord(x: unknown): x is Record<string, unknown> {
if (!x || typeof x !== 'object') return false;
for (const v of Object.values(x as Record<string, unknown>)) {
if (v !== null && typeof v === 'object') return false;
}
return true;
}
function renderTable(rows: Record<string, unknown>[]): 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');
}
+44
View File
@@ -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<string, unknown>;
};
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;
};
+38
View File
@@ -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<TArgs = unknown, TData = unknown> = {
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<string, unknown>) => TArgs;
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
};
const registry = new Map<string, CommandDef>();
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): 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));
}
+53
View File
@@ -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' },
});
+77
View File
@@ -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. <message to="local_name">). 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 } };
},
},
},
});
+28
View File
@@ -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' },
});
+37
View File
@@ -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-<provider>.',
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' },
});
+15
View File
@@ -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';
+65
View File
@@ -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 } };
},
},
},
});
+58
View File
@@ -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-<channel> (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' },
});
+67
View File
@@ -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 } };
},
},
},
});
+45
View File
@@ -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' },
});
+21
View File
@@ -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' },
});
+35
View File
@@ -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' },
});
+70
View File
@@ -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' },
});
+63
View File
@@ -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<ResponseFrame> {
return new Promise((resolve, reject) => {
const client = net.createConnection(this.socketPath);
let buffer = '';
let settled = false;
const settle = (action: 'resolve' | 'reject', valueOrErr: ResponseFrame | Error): void => {
if (settled) return;
settled = true;
try {
client.end();
} catch (_e) {
// best-effort
}
if (action === 'resolve') resolve(valueOrErr as ResponseFrame);
else reject(valueOrErr as Error);
};
client.on('connect', () => {
client.write(JSON.stringify(req) + '\n');
});
client.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const idx = buffer.indexOf('\n');
if (idx < 0) return;
const line = buffer.slice(0, idx);
try {
const frame = JSON.parse(line) as ResponseFrame;
settle('resolve', frame);
} catch (e) {
settle('reject', new Error(`malformed response from host: ${e instanceof Error ? e.message : String(e)}`));
}
});
client.on('error', (err) => settle('reject', err));
client.on('close', () => {
if (!settled) {
settle('reject', new Error('host closed connection before sending response'));
}
});
});
}
}
+111
View File
@@ -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<void> {
// Stale-socket cleanup — a previous run that crashed may have left the
// file behind, and net.createServer refuses to bind to an existing path.
try {
fs.unlinkSync(socketPath);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== 'ENOENT') {
log.warn('Failed to unlink stale ncl socket (will try to bind anyway)', { socketPath, err });
}
}
const s = net.createServer((conn) => handleConnection(conn));
server = s;
await new Promise<void>((resolve, reject) => {
s.once('error', reject);
s.listen(socketPath, () => {
try {
fs.chmodSync(socketPath, 0o600);
} catch (err) {
log.warn('Failed to chmod ncl socket (continuing)', { socketPath, err });
}
log.info('ncl CLI server listening', { socketPath });
resolve();
});
});
}
export async function stopCliServer(): Promise<void> {
if (!server) return;
const s = server;
server = null;
await new Promise<void>((resolve) => s.close(() => resolve()));
}
function handleConnection(conn: net.Socket): void {
let buffer = '';
conn.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (!line) continue;
void handleFrame(conn, line);
}
});
conn.on('error', (err) => {
log.warn('ncl CLI server connection error', { err });
});
}
async function handleFrame(conn: net.Socket, line: string): Promise<void> {
let req: RequestFrame;
try {
const parsed: unknown = JSON.parse(line);
if (!isRequestFrame(parsed)) throw new Error('bad request shape');
req = parsed;
} catch (e) {
write(conn, {
id: 'unknown',
ok: false,
error: {
code: 'transport-error',
message: `bad frame: ${e instanceof Error ? e.message : String(e)}`,
},
});
return;
}
// Host caller — connecting to data/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<string, unknown>;
return typeof o.id === 'string' && typeof o.command === 'string' && typeof o.args === 'object' && o.args !== null;
}
+10
View File
@@ -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<ResponseFrame>;
}
+13 -4
View File
@@ -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[] = [];
+6 -2
View File
@@ -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,
);
+10
View File
@@ -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<void> {
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<void> {
}
stopDeliveryPolls();
stopHostSweep();
await stopCliServer();
try {
await teardownChannelAdapters();
} finally {
+12 -2
View File
@@ -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,
);