mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
feat(cli): add CRUD helper, resource definitions, and help command
Resource-first CLI: `nc groups list`, `nc wirings get <id>`, etc. Seven resources defined (groups, messaging-groups, wirings, users, roles, members, sessions) with full column documentation that serves as the single source of truth for help output and arg validation. - CRUD helper auto-registers list/get/create/update/delete from declarative resource definitions with generic SQL - Custom operations for composite-PK resources (roles grant/revoke, members add/remove) - Access model: open (reads) / approval (writes) / hidden - `nc help` lists resources; `nc <resource> help` shows fields - Positional target IDs: `nc groups get <id>` - Removed unused priority column from wirings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,12 @@ function parseArgv(argv: string[]): {
|
||||
}
|
||||
|
||||
const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0];
|
||||
|
||||
// Third positional is the target ID
|
||||
if (positional.length >= 3) {
|
||||
args.id = positional[2];
|
||||
}
|
||||
|
||||
return { command, args, json };
|
||||
}
|
||||
|
||||
|
||||
+23
-16
@@ -5,12 +5,15 @@
|
||||
* formats the response, exits non-zero on error.
|
||||
*
|
||||
* Usage:
|
||||
* nc <command> [--key value ...] [--json]
|
||||
* nc <resource> <verb> [target] [--key value ...] [--json]
|
||||
*
|
||||
* Examples:
|
||||
* nc list-groups
|
||||
* nc list groups # space-separated form is auto-joined
|
||||
* nc list-groups --json
|
||||
* nc groups list
|
||||
* nc groups get abc123
|
||||
* nc groups create --name foo --folder bar
|
||||
* nc groups update abc123 --name baz
|
||||
* nc help
|
||||
* nc groups help
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@@ -44,9 +47,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
function pickTransport(): Transport {
|
||||
// Container DB transport will land alongside the agent-runner change.
|
||||
// For now: host-only — the only callers are a shell user or Claude in
|
||||
// the project.
|
||||
return new SocketTransport();
|
||||
}
|
||||
|
||||
@@ -85,10 +85,20 @@ function parseArgv(argv: string[]): {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Allow `nc list groups` as well as `nc list-groups`. Server rejects
|
||||
// unknowns, so the naive join is safe — at worst the user gets an
|
||||
// unknown-command error.
|
||||
const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0];
|
||||
// Single word: `nc help`
|
||||
// Two words: `nc groups list`, `nc groups help`
|
||||
// Three words: `nc groups get abc123`
|
||||
let command: string;
|
||||
if (positional.length === 1) {
|
||||
command = positional[0];
|
||||
} else {
|
||||
command = `${positional[0]}-${positional[1]}`;
|
||||
}
|
||||
|
||||
// Third positional is the target ID
|
||||
if (positional.length >= 3) {
|
||||
args.id = positional[2];
|
||||
}
|
||||
|
||||
return { command, args, json };
|
||||
}
|
||||
@@ -96,12 +106,9 @@ function parseArgv(argv: string[]): {
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
[
|
||||
'Usage: nc <command> [--key value ...] [--json]',
|
||||
'Usage: nc <resource> <verb> [target] [--key value ...] [--json]',
|
||||
'',
|
||||
'Commands:',
|
||||
' list-groups List all agent groups.',
|
||||
'',
|
||||
'Run `nc <command> --json` for machine-readable output.',
|
||||
'Run `nc help` to list available resources and commands.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Built-in help command. Introspects the resource and command registries.
|
||||
*
|
||||
* nc help — list all resources and commands
|
||||
* nc groups help — show group resource details (verbs, columns, enums)
|
||||
*/
|
||||
import { getResource, getResources } from '../crud.js';
|
||||
import { listCommands, register } from '../registry.js';
|
||||
|
||||
register({
|
||||
name: 'help',
|
||||
description: 'List available resources and commands.',
|
||||
access: 'open',
|
||||
parseArgs: () => ({}),
|
||||
handler: async () => {
|
||||
const resources = getResources();
|
||||
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (resources.length > 0) {
|
||||
lines.push('Resources:');
|
||||
for (const r of resources) {
|
||||
const ops: string[] = [];
|
||||
if (r.operations.list) ops.push('list');
|
||||
if (r.operations.get) ops.push('get');
|
||||
if (r.operations.create) ops.push('create');
|
||||
if (r.operations.update) ops.push('update');
|
||||
if (r.operations.delete) ops.push('delete');
|
||||
if (r.customOperations) ops.push(...Object.keys(r.customOperations));
|
||||
lines.push(` ${r.plural.padEnd(20)} ${r.description}`);
|
||||
lines.push(` ${''.padEnd(20)} verbs: ${ops.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
if (lines.length > 0) lines.push('');
|
||||
lines.push('Commands:');
|
||||
for (const c of commands) {
|
||||
lines.push(` ${c.name.padEnd(20)} ${c.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Run `nc <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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
// Side-effect imports — each command file calls register() at top level.
|
||||
// Imported by src/index.ts on host startup so the registry is populated
|
||||
// before the CLI server accepts connections.
|
||||
import './list-groups.js';
|
||||
/**
|
||||
* Command barrel — populates the registry before the CLI server starts.
|
||||
*
|
||||
* Resource definitions register their CRUD commands on import.
|
||||
* Help commands are registered after resources are loaded.
|
||||
*/
|
||||
import '../resources/index.js';
|
||||
import { registerResourceHelpCommands } from './help.js';
|
||||
|
||||
registerResourceHelpCommands();
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { register } from '../registry.js';
|
||||
|
||||
register({
|
||||
name: 'list-groups',
|
||||
description: 'List all agent groups.',
|
||||
riskClass: 'safe',
|
||||
parseArgs: () => ({}),
|
||||
handler: async () =>
|
||||
getAllAgentGroups().map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
folder: g.folder,
|
||||
provider: g.agent_provider ?? 'claude',
|
||||
created_at: g.created_at,
|
||||
})),
|
||||
});
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* CRUD registration helper.
|
||||
*
|
||||
* Takes a declarative resource definition (table, columns, access levels)
|
||||
* and auto-registers list/get/create/update/delete commands in the CLI
|
||||
* registry. Column metadata doubles as documentation — `nc <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(', ');
|
||||
return async () => {
|
||||
return getDb().prepare(`SELECT ${cols} FROM ${def.table}`).all();
|
||||
};
|
||||
}
|
||||
|
||||
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: () => ({}),
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Transport-agnostic dispatcher. Both the socket server (host caller) and
|
||||
* — once it lands — the per-session DB poller (container caller) call
|
||||
* dispatch() with the same frame and a transport-supplied CallerContext.
|
||||
* the per-session DB poller (container caller) call dispatch() with the
|
||||
* same frame and a transport-supplied CallerContext.
|
||||
*
|
||||
* Approval gating for risky calls from the container is the only branch
|
||||
* that differs by caller. Host callers and `safe` commands run inline.
|
||||
* that differs by caller. Host callers and `open` commands run inline.
|
||||
*/
|
||||
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { lookup } from './registry.js';
|
||||
@@ -15,13 +15,13 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
return err(req.id, 'unknown-command', `no command "${req.command}"`);
|
||||
}
|
||||
|
||||
// Agent + risky → approval flow. Wired alongside the first risky command;
|
||||
// until then, return a clear pending-shaped error so the contract is visible.
|
||||
if (ctx.caller !== 'host' && cmd.riskClass !== 'safe') {
|
||||
// Agent + approval-gated → approval flow. Wired alongside the first
|
||||
// approval-requiring command; until then, return a clear error.
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval') {
|
||||
return err(
|
||||
req.id,
|
||||
'approval-pending',
|
||||
'Approval flow not yet wired. (Will be added when the first risky command lands.)',
|
||||
'This command requires approval. (Approval flow not yet wired.)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -7,12 +7,14 @@
|
||||
*/
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
export type RiskClass = 'safe' | 'requires-admin' | 'requires-owner';
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
|
||||
export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
name: string;
|
||||
description: string;
|
||||
riskClass: RiskClass;
|
||||
access: Access;
|
||||
/** Resource this command belongs to (for help grouping). */
|
||||
resource?: string;
|
||||
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
|
||||
parseArgs: (raw: Record<string, unknown>) => TArgs;
|
||||
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
table: 'agent_groups',
|
||||
description:
|
||||
'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.',
|
||||
required: true,
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'folder',
|
||||
type: 'string',
|
||||
description:
|
||||
'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'agent_provider',
|
||||
type: 'string',
|
||||
description: 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-<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,11 @@
|
||||
/**
|
||||
* Resource barrel — imports each resource module for its side-effect
|
||||
* `registerResource(...)` call.
|
||||
*/
|
||||
import './groups.js';
|
||||
import './messaging-groups.js';
|
||||
import './wirings.js';
|
||||
import './users.js';
|
||||
import './roles.js';
|
||||
import './members.js';
|
||||
import './sessions.js';
|
||||
@@ -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,50 @@
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'messaging-group',
|
||||
plural: 'messaging-groups',
|
||||
table: 'messaging_groups',
|
||||
description:
|
||||
'Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{
|
||||
name: 'channel_type',
|
||||
type: 'string',
|
||||
description: 'Channel adapter type — matches the adapter registered by /add-<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: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getDb } from '../../db/connection.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'role',
|
||||
plural: 'roles',
|
||||
table: 'user_roles',
|
||||
description:
|
||||
'User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).',
|
||||
idColumn: 'user_id',
|
||||
columns: [
|
||||
{ name: 'user_id', type: 'string', description: 'User receiving the role. Must exist in users table.' },
|
||||
{
|
||||
name: 'role',
|
||||
type: 'string',
|
||||
description: '"owner" has full control, always global. "admin" can manage groups and approve actions.',
|
||||
enum: ['owner', 'admin'],
|
||||
},
|
||||
{
|
||||
name: 'agent_group_id',
|
||||
type: 'string',
|
||||
description: 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.',
|
||||
},
|
||||
{ name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' },
|
||||
{ name: 'granted_at', type: 'string', description: 'Auto-set.' },
|
||||
],
|
||||
operations: { list: 'open' },
|
||||
customOperations: {
|
||||
grant: {
|
||||
access: 'approval',
|
||||
description: 'Grant a role. Use --user, --role, and optionally --group for scoped admin.',
|
||||
handler: async (args) => {
|
||||
const userId = args.user as string;
|
||||
const role = args.role as string;
|
||||
const groupId = (args.group as string) ?? null;
|
||||
const grantedBy = (args.granted_by as string) ?? null;
|
||||
if (!userId) throw new Error('--user is required');
|
||||
if (!role || !['owner', 'admin'].includes(role)) throw new Error('--role must be owner or admin');
|
||||
if (role === 'owner' && groupId) throw new Error('owner role is always global (do not pass --group)');
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
)
|
||||
.run(userId, role, groupId, grantedBy);
|
||||
return { user_id: userId, role, agent_group_id: groupId };
|
||||
},
|
||||
},
|
||||
revoke: {
|
||||
access: 'approval',
|
||||
description: 'Revoke a role. Use --user, --role, and --group if scoped.',
|
||||
handler: async (args) => {
|
||||
const userId = args.user as string;
|
||||
const role = args.role as string;
|
||||
const groupId = (args.group as string) ?? null;
|
||||
if (!userId) throw new Error('--user is required');
|
||||
if (!role) throw new Error('--role is required');
|
||||
const result = getDb()
|
||||
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS ?')
|
||||
.run(userId, role, groupId);
|
||||
if (result.changes === 0) throw new Error('role not found');
|
||||
return { revoked: { user_id: userId, role, agent_group_id: groupId } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'session',
|
||||
plural: 'sessions',
|
||||
table: 'sessions',
|
||||
description:
|
||||
'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' },
|
||||
{
|
||||
name: 'messaging_group_id',
|
||||
type: 'string',
|
||||
description: 'Messaging group this session serves. Null for agent-shared sessions.',
|
||||
},
|
||||
{
|
||||
name: 'thread_id',
|
||||
type: 'string',
|
||||
description: 'Thread ID. Only set for per-thread session mode.',
|
||||
},
|
||||
{
|
||||
name: 'agent_provider',
|
||||
type: 'string',
|
||||
description: 'Provider override. Null means inherit from agent group.',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
description: '"active" receives messages. "closed" is archived.',
|
||||
enum: ['active', 'closed'],
|
||||
},
|
||||
{
|
||||
name: 'container_status',
|
||||
type: 'string',
|
||||
description: '"running" — container alive. "idle" — exited, restarts on next message. "stopped" — needs explicit wake.',
|
||||
enum: ['running', 'idle', 'stopped'],
|
||||
},
|
||||
{ name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' },
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open' },
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'user',
|
||||
plural: 'users',
|
||||
table: 'users',
|
||||
description:
|
||||
'User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
description: 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'kind',
|
||||
type: 'string',
|
||||
description:
|
||||
'Channel type identifier (e.g. "telegram", "discord"). Used as a fallback for DM resolution when the id prefix doesn\'t match a registered adapter.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'display_name',
|
||||
type: 'string',
|
||||
description: 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.',
|
||||
updatable: true,
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'wiring',
|
||||
plural: 'wirings',
|
||||
table: 'messaging_group_agents',
|
||||
description:
|
||||
'Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{
|
||||
name: 'messaging_group_id',
|
||||
type: 'string',
|
||||
description: 'The chat/channel to route from. References messaging_groups.id.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'agent_group_id',
|
||||
type: 'string',
|
||||
description: 'The agent that handles messages. References agent_groups.id.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'engage_mode',
|
||||
type: 'string',
|
||||
description:
|
||||
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.',
|
||||
enum: ['pattern', 'mention', 'mention-sticky'],
|
||||
default: 'mention',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'engage_pattern',
|
||||
type: 'string',
|
||||
description:
|
||||
'Regex for engage_mode=pattern. Required when mode is pattern. Use "." to match every message (always-on). Ignored for mention modes.',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'sender_scope',
|
||||
type: 'string',
|
||||
description: '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.',
|
||||
enum: ['all', 'known'],
|
||||
default: 'all',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'ignored_message_policy',
|
||||
type: 'string',
|
||||
description:
|
||||
'What happens to messages that don\'t trigger engagement. "drop" — agent never sees them. "accumulate" — stored as background context (trigger=0) so the agent has prior context when eventually triggered.',
|
||||
enum: ['drop', 'accumulate'],
|
||||
default: 'drop',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'session_mode',
|
||||
type: 'string',
|
||||
description:
|
||||
'"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent.',
|
||||
enum: ['shared', 'per-thread', 'agent-shared'],
|
||||
default: 'shared',
|
||||
updatable: true,
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
});
|
||||
Reference in New Issue
Block a user