diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/nc.ts index 319c63a..cc3883e 100644 --- a/container/agent-runner/src/cli/nc.ts +++ b/container/agent-runner/src/cli/nc.ts @@ -168,6 +168,12 @@ function parseArgv(argv: string[]): { } const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + + // Third positional is the target ID + if (positional.length >= 3) { + args.id = positional[2]; + } + return { command, args, json }; } diff --git a/src/cli/client.ts b/src/cli/client.ts index db1e30a..36197ad 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -5,12 +5,15 @@ * formats the response, exits non-zero on error. * * Usage: - * nc [--key value ...] [--json] + * nc [target] [--key value ...] [--json] * * Examples: - * nc list-groups - * nc list groups # space-separated form is auto-joined - * nc list-groups --json + * nc groups list + * nc groups get abc123 + * nc groups create --name foo --folder bar + * nc groups update abc123 --name baz + * nc help + * nc groups help */ import { randomUUID } from 'crypto'; @@ -44,9 +47,6 @@ async function main(): Promise { } function pickTransport(): Transport { - // Container DB transport will land alongside the agent-runner change. - // For now: host-only — the only callers are a shell user or Claude in - // the project. return new SocketTransport(); } @@ -85,10 +85,20 @@ function parseArgv(argv: string[]): { process.exit(2); } - // Allow `nc list groups` as well as `nc list-groups`. Server rejects - // unknowns, so the naive join is safe — at worst the user gets an - // unknown-command error. - const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + // Single word: `nc help` + // Two words: `nc groups list`, `nc groups help` + // Three words: `nc groups get abc123` + let command: string; + if (positional.length === 1) { + command = positional[0]; + } else { + command = `${positional[0]}-${positional[1]}`; + } + + // Third positional is the target ID + if (positional.length >= 3) { + args.id = positional[2]; + } return { command, args, json }; } @@ -96,12 +106,9 @@ function parseArgv(argv: string[]): { function printUsage(): void { process.stdout.write( [ - 'Usage: nc [--key value ...] [--json]', + 'Usage: nc [target] [--key value ...] [--json]', '', - 'Commands:', - ' list-groups List all agent groups.', - '', - 'Run `nc --json` for machine-readable output.', + 'Run `nc help` to list available resources and commands.', '', ].join('\n'), ); diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts new file mode 100644 index 0000000..9219b70 --- /dev/null +++ b/src/cli/commands/help.ts @@ -0,0 +1,106 @@ +/** + * Built-in help command. Introspects the resource and command registries. + * + * nc help — list all resources and commands + * nc groups help — show group resource details (verbs, columns, enums) + */ +import { getResource, getResources } from '../crud.js'; +import { listCommands, register } from '../registry.js'; + +register({ + name: 'help', + description: 'List available resources and commands.', + access: 'open', + parseArgs: () => ({}), + handler: async () => { + const resources = getResources(); + const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource); + + const lines: string[] = []; + if (resources.length > 0) { + lines.push('Resources:'); + for (const r of resources) { + const ops: string[] = []; + if (r.operations.list) ops.push('list'); + if (r.operations.get) ops.push('get'); + if (r.operations.create) ops.push('create'); + if (r.operations.update) ops.push('update'); + if (r.operations.delete) ops.push('delete'); + if (r.customOperations) ops.push(...Object.keys(r.customOperations)); + lines.push(` ${r.plural.padEnd(20)} ${r.description}`); + lines.push(` ${''.padEnd(20)} verbs: ${ops.join(', ')}`); + } + } + + if (commands.length > 0) { + if (lines.length > 0) lines.push(''); + lines.push('Commands:'); + for (const c of commands) { + lines.push(` ${c.name.padEnd(20)} ${c.description}`); + } + } + + lines.push(''); + lines.push('Run `nc help` for detailed field information.'); + return lines.join('\n'); + }, +}); + +// Register per-resource help commands. These are registered dynamically +// after the resources barrel has been imported. +// We use a lazy approach: register a catch-all pattern isn't possible with +// the flat registry, so we register `-help` for each resource +// in a post-import hook. +export function registerResourceHelpCommands(): void { + for (const res of getResources()) { + // Skip if already registered (e.g. from a previous call) + try { + register({ + name: `${res.plural}-help`, + description: `Show ${res.name} resource details.`, + access: 'open', + resource: res.plural, + parseArgs: () => ({}), + handler: async () => { + const lines: string[] = []; + lines.push(`${res.plural}: ${res.description}`); + lines.push(''); + + // Verbs + const verbs: string[] = []; + if (res.operations.list) verbs.push(`list [open]`); + if (res.operations.get) verbs.push(`get [open]`); + if (res.operations.create) verbs.push(`create [approval]`); + if (res.operations.update) verbs.push(`update [approval]`); + if (res.operations.delete) verbs.push(`delete [approval]`); + if (res.customOperations) { + for (const [verb, op] of Object.entries(res.customOperations)) { + verbs.push(`${verb} [${op.access}] — ${op.description}`); + } + } + lines.push('Verbs:'); + for (const v of verbs) lines.push(` ${v}`); + lines.push(''); + + // Columns + lines.push('Fields:'); + for (const col of res.columns) { + const tags: string[] = []; + if (col.generated) tags.push('auto'); + if (col.required) tags.push('required'); + if (col.updatable) tags.push('updatable'); + if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`); + if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`); + + const flag = `--${col.name.replace(/_/g, '-')}`; + const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : ''; + lines.push(` ${flag.padEnd(28)} ${col.description}${tagStr}`); + } + return lines.join('\n'); + }, + }); + } catch { + // Already registered — skip + } + } +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index f37e5ca..5b05345 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,4 +1,10 @@ -// Side-effect imports — each command file calls register() at top level. -// Imported by src/index.ts on host startup so the registry is populated -// before the CLI server accepts connections. -import './list-groups.js'; +/** + * Command barrel — populates the registry before the CLI server starts. + * + * Resource definitions register their CRUD commands on import. + * Help commands are registered after resources are loaded. + */ +import '../resources/index.js'; +import { registerResourceHelpCommands } from './help.js'; + +registerResourceHelpCommands(); diff --git a/src/cli/commands/list-groups.ts b/src/cli/commands/list-groups.ts deleted file mode 100644 index 98b87f1..0000000 --- a/src/cli/commands/list-groups.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getAllAgentGroups } from '../../db/agent-groups.js'; -import { register } from '../registry.js'; - -register({ - name: 'list-groups', - description: 'List all agent groups.', - riskClass: 'safe', - parseArgs: () => ({}), - handler: async () => - getAllAgentGroups().map((g) => ({ - id: g.id, - name: g.name, - folder: g.folder, - provider: g.agent_provider ?? 'claude', - created_at: g.created_at, - })), -}); diff --git a/src/cli/crud.ts b/src/cli/crud.ts new file mode 100644 index 0000000..370b9ad --- /dev/null +++ b/src/cli/crud.ts @@ -0,0 +1,280 @@ +/** + * CRUD registration helper. + * + * Takes a declarative resource definition (table, columns, access levels) + * and auto-registers list/get/create/update/delete commands in the CLI + * registry. Column metadata doubles as documentation — `nc help` + * is generated from the same definitions. + */ +import { randomUUID } from 'crypto'; + +import { getDb } from '../db/connection.js'; +import { register } from './registry.js'; +import type { CallerContext } from './frame.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Access = 'open' | 'approval' | 'hidden'; + +export interface ColumnDef { + name: string; + type: 'string' | 'number' | 'boolean' | 'json'; + description: string; + /** Auto-set on create — not user-provided. */ + generated?: boolean; + /** Must be provided on create (ignored if generated). */ + required?: boolean; + /** Can be changed via update. */ + updatable?: boolean; + /** Default value on create when not provided. */ + default?: unknown; + /** Allowed values (shown in help). */ + enum?: string[]; +} + +export interface CustomOperation { + access: Access; + description: string; + args?: ColumnDef[]; + handler: (args: Record, ctx: CallerContext) => Promise; +} + +export interface ResourceDef { + /** Singular name: 'group'. */ + name: string; + /** Plural name: 'groups'. Used in command names. */ + plural: string; + /** DB table name. */ + table: string; + /** One-line description shown in help. */ + description: string; + /** Primary key column name. */ + idColumn: string; + columns: ColumnDef[]; + /** Which standard CRUD operations are enabled. */ + operations: { + list?: Access; + get?: Access; + create?: Access; + update?: Access; + delete?: Access; + }; + /** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */ + customOperations?: Record; +} + +// --------------------------------------------------------------------------- +// Resource registry (for help introspection) +// --------------------------------------------------------------------------- + +const resources = new Map(); + +export function getResources(): ResourceDef[] { + return [...resources.values()].sort((a, b) => a.plural.localeCompare(b.plural)); +} + +export function getResource(plural: string): ResourceDef | undefined { + return resources.get(plural); +} + +// --------------------------------------------------------------------------- +// Generic SQL handlers +// --------------------------------------------------------------------------- + +function visibleColumns(def: ResourceDef): string[] { + return def.columns.map((c) => c.name); +} + +function genericList(def: ResourceDef) { + const cols = visibleColumns(def).join(', '); + return async () => { + return getDb().prepare(`SELECT ${cols} FROM ${def.table}`).all(); + }; +} + +function genericGet(def: ResourceDef) { + const cols = visibleColumns(def).join(', '); + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + const row = getDb() + .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) + .get(id); + if (!row) throw new Error(`${def.name} not found: ${id}`); + return row; + }; +} + +function genericCreate(def: ResourceDef) { + return async (args: Record) => { + const values: Record = {}; + + for (const col of def.columns) { + if (col.generated) { + if (col.name === def.idColumn) { + values[col.name] = randomUUID(); + } else if (col.name.endsWith('_at')) { + values[col.name] = new Date().toISOString(); + } + continue; + } + + const v = args[col.name]; + if (v !== undefined) { + if (col.enum && !col.enum.includes(String(v))) { + throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`); + } + values[col.name] = col.type === 'number' ? Number(v) : v; + } else if (col.required) { + throw new Error(`--${col.name.replace(/_/g, '-')} is required`); + } else if (col.default !== undefined) { + values[col.name] = col.default; + } + } + + const colNames = Object.keys(values); + const placeholders = colNames.map((c) => `@${c}`); + getDb() + .prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`) + .run(values); + return values; + }; +} + +function genericUpdate(def: ResourceDef) { + const updatableCols = def.columns.filter((c) => c.updatable); + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + + const updates: Record = {}; + for (const col of updatableCols) { + const v = args[col.name]; + if (v !== undefined) { + if (col.enum && !col.enum.includes(String(v))) { + throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`); + } + updates[col.name] = col.type === 'number' ? Number(v) : v; + } + } + if (Object.keys(updates).length === 0) { + throw new Error(`nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`); + } + + const setClause = Object.keys(updates) + .map((k) => `${k} = @${k}`) + .join(', '); + const result = getDb() + .prepare(`UPDATE ${def.table} SET ${setClause} WHERE ${def.idColumn} = @_id`) + .run({ ...updates, _id: id }); + if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); + + const cols = visibleColumns(def).join(', '); + return getDb() + .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) + .get(id); + }; +} + +function genericDelete(def: ResourceDef) { + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + const result = getDb() + .prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`) + .run(id); + if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); + return { deleted: id }; + }; +} + +// --------------------------------------------------------------------------- +// parseArgs helper: normalizes --hyphen-keys to underscore_keys +// --------------------------------------------------------------------------- + +function normalizeArgs(raw: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + out[k.replace(/-/g, '_')] = v; + } + return out; +} + +// --------------------------------------------------------------------------- +// registerResource +// --------------------------------------------------------------------------- + +export function registerResource(def: ResourceDef): void { + resources.set(def.plural, def); + + if (def.operations.list) { + register({ + name: `${def.plural}-list`, + description: `List all ${def.plural}.`, + access: def.operations.list, + resource: def.plural, + parseArgs: () => ({}), + handler: genericList(def), + }); + } + + if (def.operations.get) { + register({ + name: `${def.plural}-get`, + description: `Get a ${def.name} by ID.`, + access: def.operations.get, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericGet(def), + }); + } + + if (def.operations.create) { + register({ + name: `${def.plural}-create`, + description: `Create a new ${def.name}.`, + access: def.operations.create, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericCreate(def), + }); + } + + if (def.operations.update) { + register({ + name: `${def.plural}-update`, + description: `Update a ${def.name}.`, + access: def.operations.update, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericUpdate(def), + }); + } + + if (def.operations.delete) { + register({ + name: `${def.plural}-delete`, + description: `Delete a ${def.name}.`, + access: def.operations.delete, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericDelete(def), + }); + } + + // Custom operations + if (def.customOperations) { + for (const [verb, op] of Object.entries(def.customOperations)) { + register({ + name: `${def.plural}-${verb}`, + description: op.description, + access: op.access, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: async (args, ctx) => op.handler(args as Record, ctx), + }); + } + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6593750..a9943c4 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,10 +1,10 @@ /** * Transport-agnostic dispatcher. Both the socket server (host caller) and - * — once it lands — the per-session DB poller (container caller) call - * dispatch() with the same frame and a transport-supplied CallerContext. + * the per-session DB poller (container caller) call dispatch() with the + * same frame and a transport-supplied CallerContext. * * Approval gating for risky calls from the container is the only branch - * that differs by caller. Host callers and `safe` commands run inline. + * that differs by caller. Host callers and `open` commands run inline. */ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { lookup } from './registry.js'; @@ -15,13 +15,13 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise = { name: string; description: string; - riskClass: RiskClass; + access: Access; + /** Resource this command belongs to (for help grouping). */ + resource?: string; /** Validates `frame.args` and produces the typed handler input. Throws on invalid. */ parseArgs: (raw: Record) => TArgs; handler: (args: TArgs, ctx: CallerContext) => Promise; diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts new file mode 100644 index 0000000..5181bee --- /dev/null +++ b/src/cli/resources/groups.ts @@ -0,0 +1,36 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'group', + plural: 'groups', + table: 'agent_groups', + description: + 'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'name', + type: 'string', + description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.', + required: true, + updatable: true, + }, + { + name: 'folder', + type: 'string', + description: + 'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.', + required: true, + }, + { + name: 'agent_provider', + type: 'string', + description: 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', + updatable: true, + default: null, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); diff --git a/src/cli/resources/index.ts b/src/cli/resources/index.ts new file mode 100644 index 0000000..42155e7 --- /dev/null +++ b/src/cli/resources/index.ts @@ -0,0 +1,11 @@ +/** + * Resource barrel — imports each resource module for its side-effect + * `registerResource(...)` call. + */ +import './groups.js'; +import './messaging-groups.js'; +import './wirings.js'; +import './users.js'; +import './roles.js'; +import './members.js'; +import './sessions.js'; diff --git a/src/cli/resources/members.ts b/src/cli/resources/members.ts new file mode 100644 index 0000000..ac529be --- /dev/null +++ b/src/cli/resources/members.ts @@ -0,0 +1,65 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'member', + plural: 'members', + table: 'agent_group_members', + description: + 'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".', + idColumn: 'user_id', + columns: [ + { + name: 'user_id', + type: 'string', + description: 'The user to grant membership. Must reference an existing user (users.id).', + }, + { + name: 'agent_group_id', + type: 'string', + description: 'The agent group to grant access to. Must reference an existing agent group (agent_groups.id).', + }, + { + name: 'added_by', + type: 'string', + description: 'User ID of whoever added this member. Informational — not enforced.', + }, + { name: 'added_at', type: 'string', description: 'ISO 8601 timestamp of when the membership was granted.' }, + ], + operations: { list: 'open' }, + customOperations: { + add: { + access: 'approval', + description: 'Add a user as a member of an agent group. Use --user and --group.', + handler: async (args) => { + const userId = args.user as string; + const groupId = args.group as string; + const addedBy = (args.added_by as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!groupId) throw new Error('--group is required'); + getDb() + .prepare( + `INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES (?, ?, ?, datetime('now'))`, + ) + .run(userId, groupId, addedBy); + return { user_id: userId, agent_group_id: groupId }; + }, + }, + remove: { + access: 'approval', + description: 'Remove a user from an agent group. Use --user and --group.', + handler: async (args) => { + const userId = args.user as string; + const groupId = args.group as string; + if (!userId) throw new Error('--user is required'); + if (!groupId) throw new Error('--group is required'); + const result = getDb() + .prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .run(userId, groupId); + if (result.changes === 0) throw new Error('member not found'); + return { removed: { user_id: userId, agent_group_id: groupId } }; + }, + }, + }, +}); diff --git a/src/cli/resources/messaging-groups.ts b/src/cli/resources/messaging-groups.ts new file mode 100644 index 0000000..edccfc0 --- /dev/null +++ b/src/cli/resources/messaging-groups.ts @@ -0,0 +1,50 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'messaging-group', + plural: 'messaging-groups', + table: 'messaging_groups', + description: + 'Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'channel_type', + type: 'string', + description: 'Channel adapter type — matches the adapter registered by /add- (e.g. telegram, discord, slack, whatsapp).', + required: true, + }, + { + name: 'platform_id', + type: 'string', + description: + 'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.', + required: true, + }, + { + name: 'name', + type: 'string', + description: 'Display name. Often auto-populated by the channel adapter.', + updatable: true, + }, + { + name: 'is_group', + type: 'number', + description: 'Multi-user group chat (1) or direct message (0). Affects session scoping.', + default: 0, + updatable: true, + }, + { + name: 'unknown_sender_policy', + type: 'string', + description: + 'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.', + enum: ['strict', 'request_approval', 'public'], + default: 'strict', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); diff --git a/src/cli/resources/roles.ts b/src/cli/resources/roles.ts new file mode 100644 index 0000000..4e0a20b --- /dev/null +++ b/src/cli/resources/roles.ts @@ -0,0 +1,66 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'role', + plural: 'roles', + table: 'user_roles', + description: + 'User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).', + idColumn: 'user_id', + columns: [ + { name: 'user_id', type: 'string', description: 'User receiving the role. Must exist in users table.' }, + { + name: 'role', + type: 'string', + description: '"owner" has full control, always global. "admin" can manage groups and approve actions.', + enum: ['owner', 'admin'], + }, + { + name: 'agent_group_id', + type: 'string', + description: 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.', + }, + { name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' }, + { name: 'granted_at', type: 'string', description: 'Auto-set.' }, + ], + operations: { list: 'open' }, + customOperations: { + grant: { + access: 'approval', + description: 'Grant a role. Use --user, --role, and optionally --group for scoped admin.', + handler: async (args) => { + const userId = args.user as string; + const role = args.role as string; + const groupId = (args.group as string) ?? null; + const grantedBy = (args.granted_by as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!role || !['owner', 'admin'].includes(role)) throw new Error('--role must be owner or admin'); + if (role === 'owner' && groupId) throw new Error('owner role is always global (do not pass --group)'); + getDb() + .prepare( + `INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES (?, ?, ?, ?, datetime('now'))`, + ) + .run(userId, role, groupId, grantedBy); + return { user_id: userId, role, agent_group_id: groupId }; + }, + }, + revoke: { + access: 'approval', + description: 'Revoke a role. Use --user, --role, and --group if scoped.', + handler: async (args) => { + const userId = args.user as string; + const role = args.role as string; + const groupId = (args.group as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!role) throw new Error('--role is required'); + const result = getDb() + .prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS ?') + .run(userId, role, groupId); + if (result.changes === 0) throw new Error('role not found'); + return { revoked: { user_id: userId, role, agent_group_id: groupId } }; + }, + }, + }, +}); diff --git a/src/cli/resources/sessions.ts b/src/cli/resources/sessions.ts new file mode 100644 index 0000000..1a3bd24 --- /dev/null +++ b/src/cli/resources/sessions.ts @@ -0,0 +1,44 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'session', + plural: 'sessions', + table: 'sessions', + description: + 'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' }, + { + name: 'messaging_group_id', + type: 'string', + description: 'Messaging group this session serves. Null for agent-shared sessions.', + }, + { + name: 'thread_id', + type: 'string', + description: 'Thread ID. Only set for per-thread session mode.', + }, + { + name: 'agent_provider', + type: 'string', + description: 'Provider override. Null means inherit from agent group.', + }, + { + name: 'status', + type: 'string', + description: '"active" receives messages. "closed" is archived.', + enum: ['active', 'closed'], + }, + { + name: 'container_status', + type: 'string', + description: '"running" — container alive. "idle" — exited, restarts on next message. "stopped" — needs explicit wake.', + enum: ['running', 'idle', 'stopped'], + }, + { name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open' }, +}); diff --git a/src/cli/resources/users.ts b/src/cli/resources/users.ts new file mode 100644 index 0000000..5cd003e --- /dev/null +++ b/src/cli/resources/users.ts @@ -0,0 +1,33 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'user', + plural: 'users', + table: 'users', + description: + 'User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).', + idColumn: 'id', + columns: [ + { + name: 'id', + type: 'string', + description: 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.', + required: true, + }, + { + name: 'kind', + type: 'string', + description: + 'Channel type identifier (e.g. "telegram", "discord"). Used as a fallback for DM resolution when the id prefix doesn\'t match a registered adapter.', + required: true, + }, + { + name: 'display_name', + type: 'string', + description: 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' }, +}); diff --git a/src/cli/resources/wirings.ts b/src/cli/resources/wirings.ts new file mode 100644 index 0000000..f04102f --- /dev/null +++ b/src/cli/resources/wirings.ts @@ -0,0 +1,69 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'wiring', + plural: 'wirings', + table: 'messaging_group_agents', + description: + 'Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'messaging_group_id', + type: 'string', + description: 'The chat/channel to route from. References messaging_groups.id.', + required: true, + }, + { + name: 'agent_group_id', + type: 'string', + description: 'The agent that handles messages. References agent_groups.id.', + required: true, + }, + { + name: 'engage_mode', + type: 'string', + description: + 'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.', + enum: ['pattern', 'mention', 'mention-sticky'], + default: 'mention', + updatable: true, + }, + { + name: 'engage_pattern', + type: 'string', + description: + 'Regex for engage_mode=pattern. Required when mode is pattern. Use "." to match every message (always-on). Ignored for mention modes.', + updatable: true, + }, + { + name: 'sender_scope', + type: 'string', + description: '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.', + enum: ['all', 'known'], + default: 'all', + updatable: true, + }, + { + name: 'ignored_message_policy', + type: 'string', + description: + 'What happens to messages that don\'t trigger engagement. "drop" — agent never sees them. "accumulate" — stored as background context (trigger=0) so the agent has prior context when eventually triggered.', + enum: ['drop', 'accumulate'], + default: 'drop', + updatable: true, + }, + { + name: 'session_mode', + type: 'string', + description: + '"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent.', + enum: ['shared', 'per-thread', 'agent-shared'], + default: 'shared', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +});