feat(setup): wire /setup slack through the SKILL.md driver — delete slack.ts

auto.ts now applies add-slack via the thin driver instead of the bespoke
runSlackChannel. The whole connect+wire procedure lives in the SKILL.md; the
host side is just the driver plus a small adapter that ensures the wire-target
agent group.

- runChannelSkill(channel, displayName): ensures the `dm-with-<name>` agent
  group exists over ncl (idempotent — a 2nd DM channel reuses it), then runs
  `.claude/skills/add-<channel>` with that folder pre-supplied as agent_folder.
  Reports the outcome via fullyApplied. All I/O injectable for tests.
- `ncl groups create` → custom op: creates the agent group AND its container
  config (a working group needs it; generic create made only a bare row),
  idempotent on folder. The minimal wire-target slice of the deferred #3.
- skill-driver: resolveRemote is now an option (so the adapter/tests can inject).
- Deleted setup/channels/slack.ts — the first bespoke channel flow retired.
  provider-contract test now points at run-channel-skill.ts.

Only slack is migrated; the other channels keep run<Channel>Channel until their
SKILL.md wire sections are converted. Tests: groups-create scaffold+idempotent;
the adapter drives the real add-slack with injected I/O (ensures group → wires
to it). 610 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-06-27 17:32:29 +03:00
parent f6c13eb6f3
commit 4c4b2ecbbe
8 changed files with 146 additions and 529 deletions
+5 -2
View File
@@ -33,7 +33,7 @@ import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
import { runSlackChannel } from './channels/slack.js';
import { runChannelSkill } from './channels/run-channel-skill.js';
import { runTeamsChannel } from './channels/teams.js';
import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.js';
@@ -569,7 +569,10 @@ async function main(): Promise<void> {
} else if (channelChoice === 'teams') {
result = await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
result = await runSlackChannel(displayName!);
// First channel migrated to the SKILL.md-driven flow (the whole
// connect+wire procedure lives in add-slack/SKILL.md). The other
// channels still use their bespoke run<Channel>Channel until converted.
result = await runChannelSkill('slack', displayName!);
} else if (channelChoice === 'imessage') {
result = await runIMessageChannel(displayName!);
} else if (channelChoice === 'other') {
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runChannelSkill } from './run-channel-skill.js';
import { normalizeName } from '../../src/modules/agent-to-agent/db/agent-destinations.js';
// Drives the real add-slack skill through the adapter with every side effect
// injected (no real ncl/git/clack): confirms it ensures the wire-target group
// for the display-name-derived folder, then applies the skill with that folder
// pre-supplied so the wiring targets it.
describe('runChannelSkill adapter', () => {
it('ensures the group, then applies the skill with agent_folder pre-supplied', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const cmds: string[] = [];
const exec = (c: string): string | void => {
cmds.push(c);
if (c.includes('conversations.open')) return 'D0SLACK\n';
};
await runChannelSkill('slack', 'Bob Smith', {
projectRoot: root,
exec,
resolveRemote: () => 'origin',
// the secrets a human would paste; agent_folder is supplied by the adapter
inputs: { bot_token: 'xoxb-x', signing_secret: 's', slack_user_id: 'U1' },
});
const folder = `dm-with-${normalizeName('Bob Smith')}`;
expect(cmds).toContain(`ncl groups create --folder '${folder}' --name 'Bob Smith'`); // wire-target ensured
expect(cmds.some((c) => c.startsWith('ncl wirings create') && c.includes(`--agent-group ${folder}`))).toBe(true); // wired to it
expect(cmds.some((c) => c.startsWith('ncl messaging-groups create') && c.includes('--channel-type slack'))).toBe(true);
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Generic channel onboarding for setup:auto — the replacement for the bespoke
* per-channel `run<Channel>Channel` flows. The entire connect+wire procedure
* lives in the channel's SKILL.md (operator walkthroughs, credential prompts,
* restart, ncl wiring), so this just:
*
* 1. ensures the wire-target agent group exists (the SKILL.md wires to it),
* 2. runs the SKILL.md through the thin driver,
* 3. reports the outcome.
*
* The agent group is created over `ncl` (the running service owns the DB; the
* setup process has none), idempotent on the folder, so adding a second DM
* channel reuses the same `dm-with-<name>` group.
*/
import * as p from '@clack/prompts';
import { fullyApplied } from '../../scripts/skill-apply.js';
import { normalizeName } from '../../src/modules/agent-to-agent/db/agent-destinations.js';
import { type ChannelFlowResult } from '../lib/back-nav.js';
import { hostExec, runSkill, type RunSkillOptions } from '../lib/skill-driver.js';
/** Wrap a value as a single-quoted shell argument. */
function shq(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export async function runChannelSkill(
channel: string,
displayName: string,
overrides: Partial<RunSkillOptions> = {},
): Promise<ChannelFlowResult> {
const projectRoot = overrides.projectRoot ?? process.cwd();
const exec = overrides.exec ?? hostExec(projectRoot);
const folder = `dm-with-${normalizeName(displayName)}`;
// Ensure the wire-target group (+ its container config) exists. Idempotent.
exec(`ncl groups create --folder ${shq(folder)} --name ${shq(displayName)}`);
const res = await runSkill(`.claude/skills/add-${channel}`, {
projectRoot,
inputs: { agent_folder: folder, ...overrides.inputs },
exec,
prompter: overrides.prompter,
resolveRemote: overrides.resolveRemote,
skipEffects: overrides.skipEffects,
});
if (fullyApplied(res)) {
p.log.success(`${channel} connected.`);
} else {
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
p.log.warn(`${channel} setup didn't fully complete — see above.`);
}
}
-521
View File
@@ -1,521 +0,0 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
*
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test → resolves workspace + bot identity
* 4. Apply the /add-slack skill via the directive engine (the skill's
* SKILL.md is the single source of truth) + restart the service
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
*
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
* greeting in Slack immediately; inbound replies require webhooks, so the
* post-install note covers that.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'node:child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { openUrl } from '../lib/browser.js';
import { isHeadless } from '../platform.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo {
teamName: string;
teamId: string;
botName: string;
botUserId: string;
}
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
const intro = await walkThroughAppCreation();
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
const token = await collectBotToken();
const signingSecret = await collectSigningSecret();
const info = await validateSlackToken(token);
const install = await applySlackSkill(token, signingSecret, info);
if (!install.ok) {
await fail(
'slack-install',
"Couldn't connect Slack.",
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
const ownerUserId = await collectSlackUserId();
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `slack:${dmChannelId}`;
const role = await askOperatorRole('Slack');
setupLog.userInput('slack_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'slack',
'--user-id', `slack:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Wiring ${agentName} to your Slack DMs…`,
done: 'Agent wired.',
},
{
extraFields: {
CHANNEL: 'slack',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/init-first-agent` in Claude Code.',
);
}
showPostInstallChecklist(info);
}
/**
* Install the Slack adapter and persist credentials by applying the `/add-slack`
* skill through the structured-directive engine. The skill's SKILL.md is the
* single source of truth — this replaces the hand-maintained setup/add-slack.sh,
* which had already drifted on the pinned adapter version.
*
* The two secrets collected above are handed to the skill's `prompt` directives
* through the in-process Prompter, so they never touch argv or disk. The engine
* runs copy/append/dep/build + env-set/env-sync; we restart the service after
* (the skill itself doesn't, by design). add-slack is fully deterministic and
* both secrets are supplied, so a healthy apply leaves nothing for an agent and
* nothing deferred — either bucket being non-empty means the install failed.
*/
async function applySlackSkill(
token: string,
signingSecret: string,
info: WorkspaceInfo,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(`Connecting Slack to @${info.botName} (${info.teamName})…`);
const prompter: Prompter = {
async ask(name) {
if (name === 'bot_token') return token;
if (name === 'signing_secret') return signingSecret;
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-slack', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Slack.", 1);
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
s.stop('Slack adapter installed.');
setupLog.step('slack-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Slack adapter.", 1);
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
const linkBlock = isHeadless()
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
: [];
note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
...linkBlock,
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' • im:write, im:history',
' • channels:read, channels:history',
' • groups:read, groups:history',
' • chat:write',
' • users:read',
' • reactions:write',
' • files:read, files:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
].join('\n'),
'Create a Slack app',
);
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
// Slack app settings" so users can bail out of Slack before we open the
// browser or ask for tokens.
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
message: 'Open Slack app settings in your browser?',
options: [
{ value: 'open', label: 'Open Slack app settings' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'open',
}));
if (choice === 'back') return 'back';
if (!isHeadless()) openUrl(SLACK_APPS_URL);
ensureAnswer(
await p.confirm({
message: 'Got your bot token and signing secret?',
initialValue: true,
}),
);
return 'continue';
}
async function collectBotToken(): Promise<string> {
const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_bot_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
if (t.length < 24) return "That's shorter than a real Slack bot token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_bot_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function collectSigningSecret(): Promise<string> {
const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_signing_secret', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
// Slack signing secrets are 32-char hex strings, but newer apps
// sometimes emit longer variants — leniently require hex only.
if (!/^[a-f0-9]{16,}$/i.test(t)) {
return 'Signing secrets are a string of hex characters';
}
return undefined;
},
}),
);
const secret = (answer as string).trim();
setupLog.userInput(
'slack_signing_secret',
`${secret.slice(0, 4)}${secret.slice(-4)}`,
);
return secret;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${SLACK_API}/auth.test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const data = (await res.json()) as {
ok?: boolean;
team?: string;
team_id?: string;
user?: string;
user_id?: string;
error?: string;
};
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
teamId: data.team_id ?? '',
botName: data.user,
botUserId: data.user_id ?? '',
};
setupLog.step('slack-validate', 'success', Date.now() - start, {
BOT_NAME: info.botName,
BOT_USER_ID: info.botUserId,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return info;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Slack didn't accept that token: ${reason}`, 1);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'slack-validate',
"Slack didn't accept that token.",
reason === 'invalid_auth' || reason === 'token_revoked'
? 'Copy the token again from OAuth & Permissions and retry setup.'
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-validate',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function collectSlackUserId(): Promise<string> {
note(
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (bottom left)',
' 2. Click "Profile"',
' 3. Click the three dots (⋮) → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Slack member ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Member ID is required';
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
return "That doesn't look like a Slack member ID (starts with U)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('slack_user_id', id);
return id;
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${SLACK_API}/conversations.open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const data = (await res.json()) as {
ok?: boolean;
channel?: { id?: string };
error?: string;
};
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
return data.channel.id;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
if (reason === 'missing_scope') {
await fail(
'slack-open-dm',
"Your Slack app is missing the im:write scope.",
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
);
}
await fail(
'slack-open-dm',
"Couldn't open a DM channel with you.",
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-open-dm',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
function showPostInstallChecklist(info: WorkspaceInfo): void {
note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
'',
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • Save Changes',
'',
' 3. In your Slack app → Interactivity & Shortcuts:',
' • Toggle "Interactivity" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
].join('\n'),
6,
),
'Finish setting up Slack',
);
}
+3 -1
View File
@@ -68,6 +68,8 @@ export interface RunSkillOptions {
prompter?: Prompter;
/** Defaults to `hostExec`. */
exec?: (cmd: string) => string | void;
/** Defaults to the fork-aware channels-branch resolver. */
resolveRemote?: (branch: string) => string;
/** Run effects the caller owns (e.g. `['restart']` when it restarts once). */
skipEffects?: string[];
}
@@ -83,7 +85,7 @@ export function runSkill(skillDir: string, opts: RunSkillOptions = {}): Promise<
inputs: opts.inputs,
prompter: opts.prompter ?? clackPrompter(),
exec: opts.exec ?? hostExec(projectRoot),
resolveRemote: channelsRemote(projectRoot),
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
skipEffects: opts.skipEffects,
});
}
+1 -1
View File
@@ -26,7 +26,7 @@ const CREATION_FILES = [
'setup/cli-agent.ts',
'setup/channels/telegram.ts',
'setup/channels/discord.ts',
'setup/channels/slack.ts',
'setup/channels/run-channel-skill.ts', // slack (and future channels) now go through the SKILL.md driver
'setup/channels/whatsapp.ts',
'setup/channels/signal.ts',
'setup/channels/imessage.ts',
+28 -4
View File
@@ -1,10 +1,14 @@
import { randomUUID } from 'crypto';
import type { AdditionalMountConfig, McpServerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
import { restartAgentGroupContainers } from '../../container-restart.js';
import { createAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
import { getDb, hasTable } from '../../db/connection.js';
import { getSession } from '../../db/sessions.js';
import { writeSessionMessage } from '../../session-manager.js';
import {
ensureContainerConfig,
getContainerConfig,
updateContainerConfigScalars,
updateContainerConfigJson,
@@ -58,11 +62,31 @@ registerResource({
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
// `delete` is intentionally not in `operations` — the generic single-table
// DELETE violates FK constraints (see #2525). The cascading handler is
// provided as `customOperations.delete` below.
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
// `create` and `delete` are custom (below): generic `create` inserts a bare
// agent_groups row but never the container_config a working group needs, and
// the generic single-table DELETE violates FK constraints (#2525).
operations: { list: 'open', get: 'open', update: 'approval' },
customOperations: {
create: {
access: 'approval',
description:
'Create (or return the existing) agent group with its container config. Idempotent on --folder. ' +
'Use --folder <slug> and --name <display name>. Workspace files are scaffolded on first spawn.',
handler: async (args) => {
const folder = args.folder as string;
if (!folder) throw new Error('--folder is required');
const name = (args.name as string) ?? folder;
const existing = getAgentGroupByFolder(folder);
if (existing) {
ensureContainerConfig(existing.id); // ensure a reused group is fully configured too
return existing;
}
const id = `ag-${randomUUID()}`;
createAgentGroup({ id, name, folder, agent_provider: null, created_at: new Date().toISOString() });
ensureContainerConfig(id);
return getAgentGroupByFolder(folder);
},
},
delete: {
access: 'approval',
description:
+14
View File
@@ -38,6 +38,7 @@ import { dispatch } from '../dispatch.js';
import './messaging-groups.js';
import './wirings.js';
import './users.js';
import './groups.js';
const HOST = { caller: 'host' as const };
function now(): string {
@@ -132,6 +133,19 @@ describe('programmatic wiring verbs', () => {
expect((r as { error: { message: string } }).error.message).toMatch(/no messaging group/i);
});
it('groups create scaffolds the container config and is idempotent on folder', async () => {
const r1 = await send('groups-create', { folder: 'dm-with-bob', name: 'Bob' });
expect(r1.ok).toBe(true);
const ag = (r1 as { data: { id: string } }).data;
expect(ag.id).toBeTruthy();
// a working group needs a container_config row — generic create never made one
expect(count('SELECT COUNT(*) c FROM container_configs WHERE agent_group_id = ?', ag.id)).toBe(1);
// idempotent on folder
const r2 = await send('groups-create', { folder: 'dm-with-bob', name: 'Bob' });
expect((r2 as { data: { id: string } }).data.id).toBe(ag.id);
expect(count('SELECT COUNT(*) c FROM agent_groups WHERE folder = ?', 'dm-with-bob')).toBe(1);
});
it('messaging-groups send errors when no group exists (lookup before routeInbound)', async () => {
const r = await send('messaging-groups-send', {
channel_type: 'resend',