mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge pull request #2350 from qwibitai/ncl
feat(cli): add ncl admin CLI
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user