From 4c4b2ecbbe66d9260b8e4e5a0b962d9568c56099 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 27 Jun 2026 17:32:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(setup):=20wire=20/setup=20slack=20through?= =?UTF-8?q?=20the=20SKILL.md=20driver=20=E2=80=94=20delete=20slack.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-` agent group exists over ncl (idempotent — a 2nd DM channel reuses it), then runs `.claude/skills/add-` 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 runChannel 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 --- setup/auto.ts | 7 +- setup/channels/run-channel-skill.test.ts | 40 ++ setup/channels/run-channel-skill.ts | 55 +++ setup/channels/slack.ts | 521 ----------------------- setup/lib/skill-driver.ts | 4 +- setup/provider-contract.test.ts | 2 +- src/cli/resources/groups.ts | 32 +- src/cli/resources/wirings.test.ts | 14 + 8 files changed, 146 insertions(+), 529 deletions(-) create mode 100644 setup/channels/run-channel-skill.test.ts create mode 100644 setup/channels/run-channel-skill.ts delete mode 100644 setup/channels/slack.ts diff --git a/setup/auto.ts b/setup/auto.ts index 0e3431fdb..8216165bf 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -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 { } 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 runChannel until converted. + result = await runChannelSkill('slack', displayName!); } else if (channelChoice === 'imessage') { result = await runIMessageChannel(displayName!); } else if (channelChoice === 'other') { diff --git a/setup/channels/run-channel-skill.test.ts b/setup/channels/run-channel-skill.test.ts new file mode 100644 index 000000000..458152443 --- /dev/null +++ b/setup/channels/run-channel-skill.test.ts @@ -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); + }); +}); diff --git a/setup/channels/run-channel-skill.ts b/setup/channels/run-channel-skill.ts new file mode 100644 index 000000000..690b1db89 --- /dev/null +++ b/setup/channels/run-channel-skill.ts @@ -0,0 +1,55 @@ +/** + * Generic channel onboarding for setup:auto — the replacement for the bespoke + * per-channel `runChannel` 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-` 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 = {}, +): Promise { + 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.`); + } +} diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts deleted file mode 100644 index 7bdbce9cf..000000000 --- a/setup/channels/slack.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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:///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:///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', - ); -} diff --git a/setup/lib/skill-driver.ts b/setup/lib/skill-driver.ts index 3cde11958..b48e27d61 100644 --- a/setup/lib/skill-driver.ts +++ b/setup/lib/skill-driver.ts @@ -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, }); } diff --git a/setup/provider-contract.test.ts b/setup/provider-contract.test.ts index 1f1acf1ae..722429103 100644 --- a/setup/provider-contract.test.ts +++ b/setup/provider-contract.test.ts @@ -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', diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 135db9016..18de21372 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -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 and --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: diff --git a/src/cli/resources/wirings.test.ts b/src/cli/resources/wirings.test.ts index d547fe1c1..219d9c18f 100644 --- a/src/cli/resources/wirings.test.ts +++ b/src/cli/resources/wirings.test.ts @@ -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',