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:
gavrielc
2026-05-06 00:33:10 +03:00
parent 5e2bf1cb54
commit 6865811147
16 changed files with 810 additions and 46 deletions

View 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' },
});

View 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';

View File

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

View 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' },
});

View 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 } };
},
},
},
});

View 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' },
});

View 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' },
});

View 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' },
});