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:
36
src/cli/resources/groups.ts
Normal file
36
src/cli/resources/groups.ts
Normal file
@@ -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' },
|
||||
});
|
||||
11
src/cli/resources/index.ts
Normal file
11
src/cli/resources/index.ts
Normal file
@@ -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';
|
||||
65
src/cli/resources/members.ts
Normal file
65
src/cli/resources/members.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getDb } from '../../db/connection.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'member',
|
||||
plural: 'members',
|
||||
table: 'agent_group_members',
|
||||
description:
|
||||
'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".',
|
||||
idColumn: 'user_id',
|
||||
columns: [
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'string',
|
||||
description: 'The user to grant membership. Must reference an existing user (users.id).',
|
||||
},
|
||||
{
|
||||
name: 'agent_group_id',
|
||||
type: 'string',
|
||||
description: 'The agent group to grant access to. Must reference an existing agent group (agent_groups.id).',
|
||||
},
|
||||
{
|
||||
name: 'added_by',
|
||||
type: 'string',
|
||||
description: 'User ID of whoever added this member. Informational — not enforced.',
|
||||
},
|
||||
{ name: 'added_at', type: 'string', description: 'ISO 8601 timestamp of when the membership was granted.' },
|
||||
],
|
||||
operations: { list: 'open' },
|
||||
customOperations: {
|
||||
add: {
|
||||
access: 'approval',
|
||||
description: 'Add a user as a member of an agent group. Use --user and --group.',
|
||||
handler: async (args) => {
|
||||
const userId = args.user as string;
|
||||
const groupId = args.group as string;
|
||||
const addedBy = (args.added_by as string) ?? null;
|
||||
if (!userId) throw new Error('--user is required');
|
||||
if (!groupId) throw new Error('--group is required');
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
)
|
||||
.run(userId, groupId, addedBy);
|
||||
return { user_id: userId, agent_group_id: groupId };
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
access: 'approval',
|
||||
description: 'Remove a user from an agent group. Use --user and --group.',
|
||||
handler: async (args) => {
|
||||
const userId = args.user as string;
|
||||
const groupId = args.group as string;
|
||||
if (!userId) throw new Error('--user is required');
|
||||
if (!groupId) throw new Error('--group is required');
|
||||
const result = getDb()
|
||||
.prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
|
||||
.run(userId, groupId);
|
||||
if (result.changes === 0) throw new Error('member not found');
|
||||
return { removed: { user_id: userId, agent_group_id: groupId } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
50
src/cli/resources/messaging-groups.ts
Normal file
50
src/cli/resources/messaging-groups.ts
Normal file
@@ -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' },
|
||||
});
|
||||
66
src/cli/resources/roles.ts
Normal file
66
src/cli/resources/roles.ts
Normal file
@@ -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 } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
44
src/cli/resources/sessions.ts
Normal file
44
src/cli/resources/sessions.ts
Normal file
@@ -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' },
|
||||
});
|
||||
33
src/cli/resources/users.ts
Normal file
33
src/cli/resources/users.ts
Normal file
@@ -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' },
|
||||
});
|
||||
69
src/cli/resources/wirings.ts
Normal file
69
src/cli/resources/wirings.ts
Normal file
@@ -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