From 306fa6f014319c81b24c11196d5a14a651ee85a3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 4 May 2026 12:21:43 +0000 Subject: [PATCH 01/15] feat(setup): clearer "Open Telegram" copy + mobile fallback hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two friction points in the Telegram channel's "Open Telegram" card, both surfaced when running setup on a VM-via-SSH where the user's local laptop has no Telegram client installed: 1. The opening sentence read "Opening @yourbot in Telegram so it's ready when the pairing code shows up." On a headless device that's misleading — nothing is auto-opened, the user has to click the link or use their phone. Rewrite as a direct, action-led instruction on the headless flow only: Open @yourbot in Telegram now — the pairing code is coming next, and that's where you'll send it. Plus a "Get started: " line and a full-strength mobile fallback hint inside the card so headless users have all self-serve options visible. On non-headless the original status-style line stays accurate (`xdg-open` / `open` does fire for users with Telegram desktop installed), so the card stays a single line. 2. Clicking `https://t.me/yourbot` silently fails when the user's local device has no Telegram client. Non-headless gains: - a "(must be installed here)" qualifier on the confirm prompt so users without Telegram desktop know up-front; - a single combined dim fallback line below the prompt: "If browser does not appear, please visit: — or search for @yourbot on your mobile." Direct `p.confirm` + `openUrl` instead of `confirmThenOpen` for the non-headless branch so we control the dim line fully (single combined line vs the helper's default URL-only line). Headless layout drives the same self-serve content via the card body itself; no confirm prompt fires there. --- setup/channels/telegram.ts | 40 ++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index bf474f2..7117f5d 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,8 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { openUrl } from '../lib/browser.js'; +import { isHeadless } from '../platform.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -48,14 +49,37 @@ export async function runTelegramChannel(displayName: string): Promise { // installed, or the bot's web profile if not. tg://resolve?domain= is // more direct but silently fails when the scheme isn't registered. const botUrl = `https://t.me/${botUsername}`; - note( - [ + // Two card variants — auto-open fires only on GUI, so headless users + // need full self-serve instructions inside the card itself, while GUI + // users get a leaner status line plus the auto-open + a single + // combined dim fallback line (URL + mobile alternative) on the + // confirm prompt below. + if (isHeadless()) { + note( + [ + `Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`, + '', + `Get started: ${botUrl}`, + '', + `Don't have Telegram installed here? Open it on any device and search for @${botUsername}`, + ].join('\n'), + 'Open Telegram', + ); + } else { + note( `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, - formatNoteLink(botUrl), - ].filter((line): line is string => line !== null).join('\n'), - 'Open Telegram', - ); - await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); + 'Open Telegram', + ); + ensureAnswer( + await p.confirm({ + message: `Press Enter to open Telegram (must be installed here)\n${k.dim( + `If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`, + )}`, + initialValue: true, + }), + ); + openUrl(botUrl); + } const install = await runQuietChild( 'telegram-install', From 32dba601fe68855e657e72282a1b5f9dec1fb0cc Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 5 May 2026 00:24:37 +0200 Subject: [PATCH 02/15] fix(channels): support display cards (send_card) in Chat SDK bridge The send_card MCP tool wrote outbound rows with type='card' but the chat-sdk-bridge deliver() had no branch for them, so the payload fell through to the text fallback (where text is undefined) and silently returned without calling the adapter. delivery.ts then marked the message delivered with platformMsgId=undefined and the user saw nothing. Add a dedicated card branch mirroring the ask_question structure: - Build Card from title, description, and string-or-{text} children - Render only URL actions as LinkButtons (send_card is fire-and-forget per its docstring, so callback buttons would have nowhere to land) - Drop empty cards with a warn log instead of posting blank - Fall back text: content.fallbackText > description > title Affects every Chat SDK adapter that goes through the bridge: Discord, Telegram, Slack, Teams, GChat, GitHub, Linear, WhatsApp Cloud, iMessage, Matrix, Webex, Resend. Tests: adds five cases covering normal render, action filtering, link-button rendering, empty-card skip, and a regression check that non-card chat-sdk payloads still flow through the text branch. Closes #2263 --- src/channels/chat-sdk-bridge.test.ts | 132 ++++++++++++++++++++++++++- src/channels/chat-sdk-bridge.ts | 63 +++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 7e3c4ff..3697233 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { Adapter } from 'chat'; +import type { Adapter, AdapterPostableMessage, RawMessage } from 'chat'; import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js'; @@ -8,6 +8,23 @@ function stubAdapter(partial: Partial): Adapter { return { name: 'stub', ...partial } as unknown as Adapter; } +interface PostCall { + threadId: string; + message: AdapterPostableMessage; +} + +function makePostCapture() { + const calls: PostCall[] = []; + const postMessage = async ( + threadId: string, + message: AdapterPostableMessage, + ): Promise> => { + calls.push({ threadId, message }); + return { id: 'msg-stub', threadId, raw: {} }; + }; + return { calls, postMessage }; +} + describe('splitForLimit', () => { it('returns a single chunk when text fits', () => { expect(splitForLimit('short text', 100)).toEqual(['short text']); @@ -78,3 +95,116 @@ describe('createChatSdkBridge', () => { expect(typeof bridge.subscribe).toBe('function'); }); }); + +describe('createChatSdkBridge.deliver — display cards (send_card)', () => { + // The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`. + // Before this branch existed the bridge silently dropped them: cards have no + // `text` / `markdown`, so the trailing fallback `if (text)` was false and the + // function returned without calling the adapter. These tests pin the contract + // for the dedicated card branch. + + it('renders title, description, and string children, then posts via the adapter', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + const id = await bridge.deliver('telegram:42', null, { + kind: 'chat-sdk', + content: { + type: 'card', + card: { + title: 'Daily', + description: 'Your plate today', + children: ['• item one', '• item two'], + }, + fallbackText: 'Daily: your plate', + }, + }); + expect(id).toBe('msg-stub'); + expect(calls).toHaveLength(1); + const msg = calls[0].message as { card?: unknown; fallbackText?: string }; + expect(msg.fallbackText).toBe('Daily: your plate'); + expect(msg.card).toBeDefined(); + }); + + it('drops actions without url (send_card is fire-and-forget; non-URL buttons would have nowhere to land)', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + await bridge.deliver('discord:guild:chan', null, { + kind: 'chat-sdk', + content: { + type: 'card', + card: { + title: 'Card', + description: 'has only label-only actions', + actions: [{ label: 'Add' }, { label: 'Skip' }], + }, + }, + }); + expect(calls).toHaveLength(1); + // Cast through the public Card shape to read the children we set + const msg = calls[0].message as { card?: { children?: Array<{ type?: string }> } }; + const childTypes = (msg.card?.children ?? []).map((c) => c.type); + expect(childTypes).not.toContain('actions'); + }); + + it('renders url actions as link buttons inside an Actions row', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + await bridge.deliver('discord:guild:chan', null, { + kind: 'chat-sdk', + content: { + type: 'card', + card: { + title: 'Docs', + actions: [{ label: 'Open', url: 'https://example.com' }, { label: 'No-link' }], + }, + }, + }); + const msg = calls[0].message as { + card?: { children?: Array<{ type?: string; children?: Array<{ type?: string; url?: string }> }> }; + }; + const actionsRow = msg.card?.children?.find((c) => c.type === 'actions'); + expect(actionsRow).toBeDefined(); + const buttons = actionsRow?.children ?? []; + expect(buttons).toHaveLength(1); + expect(buttons[0].type).toBe('link-button'); + expect(buttons[0].url).toBe('https://example.com'); + }); + + it('skips delivery when the card has neither title nor body content', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + const id = await bridge.deliver('telegram:42', null, { + kind: 'chat-sdk', + content: { type: 'card', card: {} }, + }); + expect(id).toBeUndefined(); + expect(calls).toHaveLength(0); + }); + + it('falls through to the text branch for non-card chat-sdk payloads (no regression)', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + await bridge.deliver('telegram:42', null, { + kind: 'chat-sdk', + content: { text: 'plain hello' }, + }); + expect(calls).toHaveLength(1); + const msg = calls[0].message as { markdown?: string }; + expect(msg.markdown).toBe('plain hello'); + }); +}); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 52c92ba..a28d82e 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -12,6 +12,8 @@ import { CardText, Actions, Button, + LinkButton, + type CardChild, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage, @@ -399,6 +401,67 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return result?.id; } + // Display card (send_card MCP tool) — returns immediately, no callback flow. + // Non-URL actions are dropped: send_card's contract is fire-and-forget, so a + // callback button would have nowhere to land. URL actions render as link buttons. + if (content.type === 'card' && content.card && typeof content.card === 'object') { + const cardSpec = content.card as Record; + const title = (cardSpec.title as string) || ''; + const fallbackText = + (content.fallbackText as string) || + (cardSpec.description as string) || + title || + ''; + + const cardChildren: CardChild[] = []; + if (typeof cardSpec.description === 'string' && cardSpec.description) { + cardChildren.push(CardText(cardSpec.description)); + } + if (Array.isArray(cardSpec.children)) { + for (const child of cardSpec.children) { + if (typeof child === 'string' && child) { + cardChildren.push(CardText(child)); + } else if ( + child && + typeof child === 'object' && + typeof (child as Record).text === 'string' + ) { + cardChildren.push(CardText((child as Record).text)); + } + } + } + if (Array.isArray(cardSpec.actions)) { + const linkButtons = (cardSpec.actions as Array>) + .filter( + (a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label, + ) + .map((a) => { + const style = a.style; + const safeStyle: 'primary' | 'danger' | 'default' | undefined = + style === 'primary' || style === 'danger' || style === 'default' + ? style + : undefined; + return LinkButton({ + label: a.label as string, + url: a.url as string, + style: safeStyle, + }); + }); + if (linkButtons.length > 0) { + cardChildren.push(Actions(linkButtons)); + } + } + + if (cardChildren.length === 0 && !title) { + log.warn('send_card payload empty, skipping delivery'); + return; + } + + const card = Card({ title, children: cardChildren }); + const result = await adapter.postMessage(tid, { card, fallbackText }); + return result?.id; + } + // Normal message const rawText = (content.markdown as string) || (content.text as string); const text = rawText ? transformText(rawText) : rawText; From 9633788a1b5fefe1737ed6cbbf826b1b6e28983e Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 5 May 2026 00:28:25 +0200 Subject: [PATCH 03/15] fix(skills): bump @chat-adapter/* cohort to 4.27.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @chat-adapter/discord@4.27.0 includes vercel/chat#256, which fixes the Discord adapter unconditionally setting payload.content alongside payload.embeds when posting a card. In 4.26.0 every Discord card appeared twice (text content above the embed, identical content inside the embed) — every new install reproduced this on the welcome tour and on every approval card. The other 7 skills bump in lockstep because @chat-adapter/discord@4.27.0 depends on chat@4.27.0 while @chat-adapter/@4.26.0 depend on chat@4.26.0. Mixing the cohort produces a TypeScript dual-version conflict between the bridge and adapter ChatInstance types. Files updated (one line per file in each pnpm install command): - add-discord (the user-visible bug fix) - add-gchat, add-github, add-linear, add-slack, add-teams, add-telegram, add-whatsapp-cloud (cohort consistency) Out of scope: add-imessage, add-matrix, add-webex, add-resend use third-party packages with independent versioning. Closes #2264 --- .claude/skills/add-discord/SKILL.md | 2 +- .claude/skills/add-gchat/SKILL.md | 2 +- .claude/skills/add-github/SKILL.md | 2 +- .claude/skills/add-linear/SKILL.md | 2 +- .claude/skills/add-slack/SKILL.md | 2 +- .claude/skills/add-teams/SKILL.md | 2 +- .claude/skills/add-telegram/SKILL.md | 2 +- .claude/skills/add-whatsapp-cloud/SKILL.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index 6d3ccc8..f22c0c7 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -44,7 +44,7 @@ import './discord.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/discord@4.26.0 +pnpm install @chat-adapter/discord@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-gchat/SKILL.md b/.claude/skills/add-gchat/SKILL.md index c4d8dfd..b3b7d1b 100644 --- a/.claude/skills/add-gchat/SKILL.md +++ b/.claude/skills/add-gchat/SKILL.md @@ -44,7 +44,7 @@ import './gchat.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/gchat@4.26.0 +pnpm install @chat-adapter/gchat@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-github/SKILL.md b/.claude/skills/add-github/SKILL.md index 78366f3..2441f13 100644 --- a/.claude/skills/add-github/SKILL.md +++ b/.claude/skills/add-github/SKILL.md @@ -48,7 +48,7 @@ import './github.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/github@4.26.0 +pnpm install @chat-adapter/github@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-linear/SKILL.md b/.claude/skills/add-linear/SKILL.md index dc657af..237aaa0 100644 --- a/.claude/skills/add-linear/SKILL.md +++ b/.claude/skills/add-linear/SKILL.md @@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n ### 5. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/linear@4.26.0 +pnpm install @chat-adapter/linear@4.27.0 ``` ### 6. Build diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index d09db61..0b67b50 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -44,7 +44,7 @@ import './slack.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/slack@4.26.0 +pnpm install @chat-adapter/slack@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md index 10bce29..f6eeaf9 100644 --- a/.claude/skills/add-teams/SKILL.md +++ b/.claude/skills/add-teams/SKILL.md @@ -44,7 +44,7 @@ import './teams.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/teams@4.26.0 +pnpm install @chat-adapter/teams@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index f605b41..03247c5 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe ### 5. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/telegram@4.26.0 +pnpm install @chat-adapter/telegram@4.27.0 ``` ### 6. Build diff --git a/.claude/skills/add-whatsapp-cloud/SKILL.md b/.claude/skills/add-whatsapp-cloud/SKILL.md index d08f375..7e8bd1c 100644 --- a/.claude/skills/add-whatsapp-cloud/SKILL.md +++ b/.claude/skills/add-whatsapp-cloud/SKILL.md @@ -44,7 +44,7 @@ import './whatsapp-cloud.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/whatsapp@4.26.0 +pnpm install @chat-adapter/whatsapp@4.27.0 ``` ### 5. Build From a57bb8fec032d27a80d0f5488415dd30b5c0fff4 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 5 May 2026 00:42:04 +0200 Subject: [PATCH 04/15] style: apply prettier to chat-sdk-bridge card branch --- src/channels/chat-sdk-bridge.test.ts | 5 +---- src/channels/chat-sdk-bridge.ts | 14 +++----------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 3697233..3049c29 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -15,10 +15,7 @@ interface PostCall { function makePostCapture() { const calls: PostCall[] = []; - const postMessage = async ( - threadId: string, - message: AdapterPostableMessage, - ): Promise> => { + const postMessage = async (threadId: string, message: AdapterPostableMessage): Promise> => { calls.push({ threadId, message }); return { id: 'msg-stub', threadId, raw: {} }; }; diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index a28d82e..f403dfa 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -407,11 +407,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter if (content.type === 'card' && content.card && typeof content.card === 'object') { const cardSpec = content.card as Record; const title = (cardSpec.title as string) || ''; - const fallbackText = - (content.fallbackText as string) || - (cardSpec.description as string) || - title || - ''; + const fallbackText = (content.fallbackText as string) || (cardSpec.description as string) || title || ''; const cardChildren: CardChild[] = []; if (typeof cardSpec.description === 'string' && cardSpec.description) { @@ -432,15 +428,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } if (Array.isArray(cardSpec.actions)) { const linkButtons = (cardSpec.actions as Array>) - .filter( - (a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label, - ) + .filter((a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label) .map((a) => { const style = a.style; const safeStyle: 'primary' | 'danger' | 'default' | undefined = - style === 'primary' || style === 'danger' || style === 'default' - ? style - : undefined; + style === 'primary' || style === 'danger' || style === 'default' ? style : undefined; return LinkButton({ label: a.label as string, url: a.url as string, From c795ecff6ec794e78718007b1ea2d9c8a5518cd3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:20:17 +0000 Subject: [PATCH 05/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Discord,=20WhatsApp,=20iMessage=20channel=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking the wrong messaging channel during setup left users with no way to bail out — they had to either complete the chosen flow or kill setup and start over. This adds a Back option to the first prompt of three channel sub-flows that share the same simple shape (one leading brightSelect that's easy to extend). Mechanics: - New `setup/lib/back-nav.ts` exports a BACK_TO_CHANNEL_SELECTION sentinel and ChannelFlowResult type. - `setup/auto.ts` wraps the channel dispatch in a while-loop; channels return BACK_TO_CHANNEL_SELECTION to bounce back to the chooser without restarting setup. Channels not yet wired return void and the loop exits after one pass, so the change is backwards compatible. - Discord, WhatsApp, iMessage each add a `← Back to channel selection` option to their first prompt. Telegram, Slack, Teams, and Signal will follow as separate PRs — they each need a slightly different shape (extra prompt insertions, gating inside multi-step flows, etc.) and are easier to review independently. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 67 ++++++++++++++++++++++---------------- setup/channels/discord.ts | 12 ++++--- setup/channels/imessage.ts | 54 ++++++++++++++++-------------- setup/channels/whatsapp.ts | 14 +++++--- setup/lib/back-nav.ts | 17 ++++++++++ 5 files changed, 104 insertions(+), 60 deletions(-) create mode 100644 setup/lib/back-nav.ts diff --git a/setup/auto.ts b/setup/auto.ts index b57672f..4b7ca46 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -29,6 +29,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +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'; @@ -440,35 +441,45 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { - channelChoice = await askChannelChoice(); - if (channelChoice !== 'skip' && channelChoice !== 'other') { - await resolveDisplayName(); - } - if (channelChoice === 'telegram') { - await runTelegramChannel(displayName!); - } else if (channelChoice === 'discord') { - await runDiscordChannel(displayName!); - } else if (channelChoice === 'whatsapp') { - await runWhatsAppChannel(displayName!); - } else if (channelChoice === 'signal') { - await runSignalChannel(displayName!); - } else if (channelChoice === 'teams') { - await runTeamsChannel(displayName!); - } else if (channelChoice === 'slack') { - await runSlackChannel(displayName!); - } else if (channelChoice === 'imessage') { - await runIMessageChannel(displayName!); - } else if (channelChoice === 'other') { - await askOtherChannelName(); - } else { - p.log.info( - brandBody( - wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', - 4, + // Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on + // its first prompt and bounce the user back to the chooser without + // restarting setup. Channels not yet wired with the back option just + // return void and the loop exits after one pass. + let backed = true; + while (backed) { + backed = false; + channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip' && channelChoice !== 'other') { + await resolveDisplayName(); + } + let result: void | typeof BACK_TO_CHANNEL_SELECTION; + if (channelChoice === 'telegram') { + await runTelegramChannel(displayName!); + } else if (channelChoice === 'discord') { + result = await runDiscordChannel(displayName!); + } else if (channelChoice === 'whatsapp') { + result = await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + await runSignalChannel(displayName!); + } else if (channelChoice === 'teams') { + await runTeamsChannel(displayName!); + } else if (channelChoice === 'slack') { + await runSlackChannel(displayName!); + } else if (channelChoice === 'imessage') { + result = await runIMessageChannel(displayName!); + } else if (channelChoice === 'other') { + await askOtherChannelName(); + } else { + p.log.info( + brandBody( + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', + 4, + ), ), - ), - ); + ); + } + if (result === BACK_TO_CHANNEL_SELECTION) backed = true; } } diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 28c0254..ad9da17 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -27,6 +27,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; 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 { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; @@ -48,8 +49,10 @@ interface AppInfo { owner: { id: string; username: string } | null; } -export async function runDiscordChannel(displayName: string): Promise { - const hasBot = await askHasBotToken(); +export async function runDiscordChannel(displayName: string): Promise { + const choice = await askHasBotToken(); + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + const hasBot = choice === 'yes'; if (!hasBot) { await walkThroughBotCreation(); } @@ -142,17 +145,18 @@ export async function runDiscordChannel(displayName: string): Promise { } } -async function askHasBotToken(): Promise { +async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> { const answer = ensureAnswer( await brightSelect({ message: 'Do you already have a Discord bot?', options: [ { value: 'yes', label: 'Yes, I have a bot token ready' }, { value: 'no', label: "No, walk me through creating one" }, + { value: 'back', label: '← Back to channel selection' }, ], }), ); - return answer === 'yes'; + return answer as 'yes' | 'no' | 'back'; } async function walkThroughBotCreation(): Promise { diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 8c0b78d..5730fca 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; 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 { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; @@ -48,10 +49,11 @@ interface RemoteCreds { apiKey: string; } -export async function runIMessageChannel(displayName: string): Promise { +export async function runIMessageChannel(displayName: string): Promise { const isMac = os.platform() === 'darwin'; const mode = await askMode(isMac); + if (mode === 'back') return BACK_TO_CHANNEL_SELECTION; let remoteCreds: RemoteCreds | null = null; if (mode === 'local') { @@ -139,34 +141,38 @@ export async function runIMessageChannel(displayName: string): Promise { } } -async function askMode(isMac: boolean): Promise { +async function askMode(isMac: boolean): Promise { + const baseOptions = isMac + ? [ + { + value: 'local' as const, + label: 'Local (this Mac)', + hint: "uses this machine's iMessage account", + }, + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'the bot lives on another server', + }, + ] + : [ + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'only option off macOS', + }, + ]; const choice = ensureAnswer( - await brightSelect({ + await brightSelect({ message: 'How should iMessage run?', initialValue: isMac ? 'local' : 'remote', - options: isMac - ? [ - { - value: 'local', - label: 'Local (this Mac)', - hint: "uses this machine's iMessage account", - }, - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'the bot lives on another server', - }, - ] - : [ - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'only option off macOS', - }, - ], + options: [ + ...baseOptions, + { value: 'back', label: '← Back to channel selection' }, + ], }), ); - setupLog.userInput('imessage_mode', String(choice)); + if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice)); return choice; } diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..2a0de1a 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; 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 { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; import { @@ -53,8 +54,9 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); type AuthMethod = 'qr' | 'pairing-code'; -export async function runWhatsAppChannel(displayName: string): Promise { +export async function runWhatsAppChannel(displayName: string): Promise { const method = await askAuthMethod(); + if (method === 'back') return BACK_TO_CHANNEL_SELECTION; const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined; const install = await runQuietChild( @@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { } } -async function askAuthMethod(): Promise { +async function askAuthMethod(): Promise { const choice = ensureAnswer( await brightSelect({ message: 'How would you like to authenticate with WhatsApp?', @@ -163,10 +165,14 @@ async function askAuthMethod(): Promise { label: 'Enter a pairing code on your phone', hint: 'no camera needed', }, + { + value: 'back', + label: '← Back to channel selection', + }, ], }), - ) as AuthMethod; - setupLog.userInput('whatsapp_auth_method', choice); + ) as AuthMethod | 'back'; + if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice); return choice; } diff --git a/setup/lib/back-nav.ts b/setup/lib/back-nav.ts new file mode 100644 index 0000000..586d161 --- /dev/null +++ b/setup/lib/back-nav.ts @@ -0,0 +1,17 @@ +/** + * Channel-flow back-navigation sentinel. + * + * Each `runXxxChannel(displayName)` in `setup/channels/` may return either + * `void` (sub-flow completed normally) or `BACK_TO_CHANNEL_SELECTION` to + * signal "the user picked '← Back to channel selection' on my first + * prompt; please re-run the channel chooser." `setup/auto.ts` catches + * that signal and loops back to `askChannelChoice()`. + * + * Back is only offered on the *first* interactive prompt of each channel + * sub-flow — once the user has answered something, they're committed + * (subsequent steps may have side effects like opening browsers, hitting + * APIs, or installing adapter packages, none of which are easily undone). + */ +export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION'); + +export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION; From e1ecfb9c4866c301eb2f8d601ecb3465e3928d6c Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:29:23 +0000 Subject: [PATCH 06/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Telegram=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the back-nav scaffolding from the Discord/WhatsApp/iMessage PR — depends on setup/lib/back-nav.ts and the auto.ts loop. Telegram's "no existing token" path adds one extra prompt — a brightSelect "Ready to paste your bot token?" between the BotFather instructions and the token paste. Clack's p.password prompt doesn't support menu options so we can't fold Back into the paste itself; the cleanest fix is a separate gate immediately before. The "existing token" path doesn't add noise — the Yes/No confirm becomes Yes/No/Back. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/telegram.ts | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4b7ca46..bf3ce92 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -454,7 +454,7 @@ async function main(): Promise { } let result: void | typeof BACK_TO_CHANNEL_SELECTION; if (channelChoice === 'telegram') { - await runTelegramChannel(displayName!); + result = await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { result = await runDiscordChannel(displayName!); } else if (channelChoice === 'whatsapp') { diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index bf474f2..01a6675 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,9 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -38,8 +40,10 @@ import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/th const DEFAULT_AGENT_NAME = 'Nano'; -export async function runTelegramChannel(displayName: string): Promise { - const token = await collectTelegramToken(); +export async function runTelegramChannel(displayName: string): Promise { + const tokenOrBack = await collectTelegramToken(); + if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION; + const token = tokenOrBack; const botUsername = await validateTelegramToken(token); // Deep-link the user into the bot's chat so they're on the right screen @@ -131,17 +135,24 @@ export async function runTelegramChannel(displayName: string): Promise { } } -async function collectTelegramToken(): Promise { +async function collectTelegramToken(): Promise { const existing = readEnvKey('TELEGRAM_BOT_TOKEN'); if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing token' }, + { value: 'no', label: 'No, paste a new one' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return 'back'; + if (choice === 'yes') { setupLog.userInput('telegram_token', 'reused-existing'); return existing; } + // 'no' falls through to the paste flow below } note( @@ -159,6 +170,19 @@ async function collectTelegramToken(): Promise { 'Set up your Telegram bot', ); + // Back-aware gate before the password prompt — `p.password` doesn't + // accept extra options, so we offer Back as a separate brightSelect + // immediately after the BotFather instructions and before the paste. + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to paste your bot token?', + options: [ + { value: 'continue', label: 'Yes, paste it on the next prompt' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return 'back'; + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', From 6a54b699120ac7da9202d0e9c8555d5e5f84c5f6 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:32:34 +0000 Subject: [PATCH 07/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Slack=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the back-nav scaffolding from #2269 and the Telegram PR. Slack's first prompt was already a single-purpose "Press Enter to open Slack app settings" confirm. Replacing it with a 2-option brightSelect (Open / ← Back) folds the Back gate into the existing screen — net same number of prompts as before, just with a way out. The redundant confirmThenOpen Press-Enter step is dropped; openUrl is called inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/slack.ts | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index bf3ce92..b2d6dfc 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -464,7 +464,7 @@ async function main(): Promise { } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { - await runSlackChannel(displayName!); + result = await runSlackChannel(displayName!); } else if (channelChoice === 'imessage') { result = await runIMessageChannel(displayName!); } else if (channelChoice === 'other') { diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0e3f052..0918075 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -25,7 +25,10 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; +import { formatNoteLink, 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'; @@ -42,8 +45,9 @@ interface WorkspaceInfo { botUserId: string; } -export async function runSlackChannel(displayName: string): Promise { - await walkThroughAppCreation(); +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(); @@ -121,7 +125,7 @@ export async function runSlackChannel(displayName: string): Promise { showPostInstallChecklist(info); } -async function walkThroughAppCreation(): Promise { +async function walkThroughAppCreation(): Promise<'continue' | 'back'> { note( [ "You'll create a Slack app that the assistant talks through.", @@ -140,7 +144,20 @@ async function walkThroughAppCreation(): Promise { ].filter((line): line is string => line !== null).join('\n'), 'Create a Slack app', ); - await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); + + // 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({ @@ -148,6 +165,7 @@ async function walkThroughAppCreation(): Promise { initialValue: true, }), ); + return 'continue'; } async function collectBotToken(): Promise { From c44c7a6669d30f1fd75f674bb7981723b108639b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:47:17 +0000 Subject: [PATCH 08/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Teams=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2269 (back-nav scaffolding) plus the Telegram and Slack PRs. They share the same scaffolding file from #2269 — they don't compile without it, so they have to stack. Both Teams paths already had a brightSelect at the right place, so we just extend each with a Back option — no new prompts: - Existing-credentials path: Yes/No confirm becomes Yes/No/Back - Fresh-setup path: the very first stepGate ("How did that go?") gets a 4th option. Subsequent stepGates keep the original 3 options so we never lose mid-flow state. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/teams.ts | 55 ++++++++++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index b2d6dfc..8185b22 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -462,7 +462,7 @@ async function main(): Promise { } else if (channelChoice === 'signal') { await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { - await runTeamsChannel(displayName!); + result = await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { result = await runSlackChannel(displayName!); } else if (channelChoice === 'imessage') { diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 41e2070..3691beb 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -30,6 +30,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { @@ -57,18 +58,24 @@ interface Collected { agentName?: string; } -export async function runTeamsChannel(_displayName: string): Promise { +export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; const existingAppId = readEnvKey('TEAMS_APP_ID'); const existingPassword = readEnvKey('TEAMS_APP_PASSWORD'); if (existingAppId && existingPassword) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing credentials' }, + { value: 'no', label: "No, set up new ones" }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + if (choice === 'yes') { collected.appId = existingAppId; collected.appPassword = existingPassword; collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; @@ -85,7 +92,8 @@ export async function runTeamsChannel(_displayName: string): Promise { printIntro(); - await confirmPrereqs({ collected, completed }); + const prereqsResult = await confirmPrereqs({ collected, completed }); + if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION; await stepPublicUrl({ collected, completed }); await stepAppRegistration({ collected, completed }); await stepClientSecret({ collected, completed }); @@ -116,7 +124,7 @@ function printIntro(): void { ); } -async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { +async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> { note( [ 'Before we start, confirm you have:', @@ -131,13 +139,36 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] 'Prereqs', ); - await stepGate({ - stepName: 'teams-prereqs', - stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', - reshow: () => confirmPrereqs(args), - args, - }); + // Back-aware variant of stepGate — Back is only offered on the very first + // step of the Teams flow so users can bail out before any state is taken. + while (true) { + const choice = ensureAnswer( + await brightSelect<'done' | 'help' | 'reshow' | 'back'>({ + message: 'How did that go?', + options: [ + { value: 'done', label: "Done — let's continue" }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + { value: 'reshow', label: 'Show me the steps again' }, + { value: 'back', label: '← Back to channel selection' }, + ], + }), + ); + if (choice === 'back') return 'back'; + if (choice === 'done') break; + if (choice === 'help') { + await offerHandoff({ + step: 'teams-prereqs', + stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', + args, + }); + continue; + } + if (choice === 'reshow') { + return confirmPrereqs(args); + } + } args.completed.push('Prereqs confirmed.'); + return 'continue'; } // ─── step: public URL ────────────────────────────────────────────────── From decf18049ff06f582cbdab49dc4bc0f234df97d3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:51:21 +0000 Subject: [PATCH 09/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Signal=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2269 (back-nav scaffolding) plus the Telegram, Slack, and Teams PRs. They share the same scaffolding file from #2269 — they don't compile without it, so they have to stack. Signal had no user-facing prompt before the install kicked off, so there was nothing to attach a Back option to. This adds a brief "Set up Signal" info card (what's about to happen, no new phone number needed) followed by a Continue/Back brightSelect. The card serves double duty — context for the install plus the Back gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/signal.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 8185b22..91ad83a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -460,7 +460,7 @@ async function main(): Promise { } else if (channelChoice === 'whatsapp') { result = await runWhatsAppChannel(displayName!); } else if (channelChoice === 'signal') { - await runSignalChannel(displayName!); + result = await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { result = await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 8462a56..498690f 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -33,6 +33,8 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; import { type Block, type StepResult, @@ -48,7 +50,33 @@ import { accentGreen, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; -export async function runSignalChannel(displayName: string): Promise { +export async function runSignalChannel(displayName: string): Promise { + note( + [ + "NanoClaw links to Signal as a *secondary* device on your existing", + "phone — no new number needed. Your assistant will send and receive", + "messages as the number on that phone.", + '', + "Here's what's about to happen:", + '', + ' 1. Check that signal-cli is installed (we\'ll guide you if not)', + ' 2. Install the Signal adapter', + ' 3. Show a QR code — scan it from Signal → Settings → Linked Devices', + ' 4. Wire your assistant and send a welcome message', + ].join('\n'), + 'Set up Signal', + ); + + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to set up Signal?', + options: [ + { value: 'continue', label: 'Continue' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION; + await ensureSignalCli(); const install = await runQuietChild( From 7fdd7eaa1c6c13057b1c23dd3c09f6f3661b465a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 10:14:12 +0000 Subject: [PATCH 10/15] setup: update WhatsApp link instructions to "You / Settings" WhatsApp's mobile UI calls the menu "You" on iOS and "Settings" on Android (depending on platform/version). Both QR-scan and pairing-code captions only mentioned "Settings", so iOS users had to figure out the iOS-specific path on their own. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/whatsapp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..e95d0dc 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -312,7 +312,7 @@ async function renderQr(qr: string): Promise { const QRCode = await import('qrcode'); const qrText = await QRCode.toString(qr, { type: 'terminal', small: true }); const caption = k.dim( - ' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.', + ' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.', ); return [...qrText.trimEnd().split('\n'), '', caption]; } catch { @@ -328,7 +328,7 @@ function formatPairingCard(code: string): string { '', ` ${brandBold(spaced)}`, '', - k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'), + k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'), k.dim(' → "Link with phone number instead" → enter this code.'), k.dim(' It expires in ~60 seconds.'), ].join('\n'); From 395139ce635c92567f66bd20ab838dc904cda0ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 15:04:19 +0000 Subject: [PATCH 11/15] chore: bump version to 2.0.32 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35856b7..96f4ae9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.31", + "version": "2.0.32", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 73d45f80979ed0d8d73e4cd728095ce229b013d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 15:07:05 +0000 Subject: [PATCH 12/15] =?UTF-8?q?docs:=20update=20token=20count=20to=20141?= =?UTF-8?q?k=20tokens=20=C2=B7=2071%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 263081f..e68caf4 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 141k tokens, 70% of context window + + 141k tokens, 71% of context window From 92a2347dc540437f06acafd64cd40a770f596715 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 17:04:53 +0000 Subject: [PATCH 13/15] setup: auto-install signal-cli when missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user picks Signal in setup and signal-cli isn't on PATH, today NanoClaw bails with a GitHub releases link and tells them to re-run. That's a hard wall for non-technical users — GitHub releases pages are intimidating, and the Linux native build / Java decision isn't obvious. Replace the bail-out with a real install: a new install-signal-cli.sh script that does `brew install signal-cli` on macOS or downloads the native Linux release into ~/.local/bin (no Java, no sudo). Wired into ensureSignalCli with a spinner; probe again after, fall back to the original manual-install copy if anything fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/signal.ts | 62 ++++++++++++++++++++++------- setup/install-signal-cli.sh | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 15 deletions(-) create mode 100755 setup/install-signal-cli.sh diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 8462a56..4e98ee1 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -134,42 +134,74 @@ export async function runSignalChannel(displayName: string): Promise { async function ensureSignalCli(): Promise { const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli'; - const probe = spawnSync(cli, ['--version'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (!probe.error && probe.status === 0) return; + const probeFor = (): boolean => { + const r = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + return !r.error && r.status === 0; + }; + if (probeFor()) return; + note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We'll install it for you now — about 30 seconds, one-time only.", + '', + process.platform === 'darwin' + ? "On this Mac we'll use Homebrew (no admin password needed)." + : "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.", + ].join('\n'), + 'Setting up signal-cli', + ); + + const install = await runQuietChild( + 'install-signal-cli', + 'bash', + ['setup/install-signal-cli.sh'], + { + running: 'Installing signal-cli…', + done: 'signal-cli installed.', + }, + ); + + if (install.ok && probeFor()) return; + + const reason = install.terminal?.fields.ERROR; if (process.platform === 'darwin') { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + reason === 'homebrew_not_installed' + ? ' Reason: Homebrew is not installed.' + : ` Reason: ${reason ?? 'unknown'}.`, '', - 'The quickest way on macOS is Homebrew:', + 'You can install it manually:', '', k.cyan(' brew install signal-cli'), '', - "Install it in another terminal, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } else { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + ` Reason: ${reason ?? 'unknown'}.`, '', - 'Grab the latest release from GitHub:', + 'You can install it manually from GitHub:', '', k.cyan(' https://github.com/AsamK/signal-cli/releases'), '', - "Install it, make sure `signal-cli --version` works, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } await fail( - 'signal-install', - 'signal-cli is required but not installed.', - 'Install it and re-run setup.', + 'install-signal-cli', + 'signal-cli is required but the auto-install failed.', + 'Install it manually and re-run setup.', ); } diff --git a/setup/install-signal-cli.sh b/setup/install-signal-cli.sh new file mode 100755 index 0000000..870220e --- /dev/null +++ b/setup/install-signal-cli.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# install-signal-cli.sh — auto-install signal-cli on the host. +# +# NanoClaw needs `signal-cli` on PATH to talk to Signal. Picks the right +# install method per platform: +# macOS → `brew install signal-cli` (bottled, no Java needed) +# Linux → download latest native binary from GitHub releases to +# ~/.local/bin/signal-cli (no Java, no sudo) +# +# Emits the standard NanoClaw STATUS block on success or failure so the +# `runQuietChild` driver can parse the outcome. + +set -euo pipefail + +VERSION="0.14.3" +INSTALL_DIR="${HOME}/.local/bin" + +emit_status() { + local status=$1 error=${2:-} + echo "=== NANOCLAW SETUP: INSTALL_SIGNAL_CLI ===" + echo "STATUS: ${status}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[install-signal-cli] $*" >&2; } + +uname_s=$(uname) + +if [[ "${uname_s}" == "Darwin" ]]; then + if ! command -v brew >/dev/null 2>&1; then + emit_status failed "homebrew_not_installed" + exit 1 + fi + log "Installing signal-cli via Homebrew…" + brew install signal-cli >&2 || { + emit_status failed "brew_install_failed" + exit 1 + } + emit_status success + exit 0 +fi + +if [[ "${uname_s}" != "Linux" ]]; then + emit_status failed "unsupported_platform_${uname_s}" + exit 1 +fi + +# Linux native build (no Java required) → ~/.local/bin/signal-cli. +URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" +TARBALL=$(mktemp -t signal-cli.XXXXXX.tar.gz) + +log "Downloading signal-cli v${VERSION} (~96MB)…" +if ! curl -fLsS -o "${TARBALL}" "${URL}"; then + rm -f "${TARBALL}" + emit_status failed "download_failed" + exit 1 +fi + +log "Extracting…" +EXTRACT_DIR=$(mktemp -d) +if ! tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "extract_failed" + exit 1 +fi + +mkdir -p "${INSTALL_DIR}" +log "Installing to ${INSTALL_DIR}/signal-cli…" +if ! mv "${EXTRACT_DIR}/signal-cli" "${INSTALL_DIR}/signal-cli"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "install_failed" + exit 1 +fi +chmod +x "${INSTALL_DIR}/signal-cli" +rm -rf "${TARBALL}" "${EXTRACT_DIR}" + +emit_status success From 291a1fc8a47f5c0949c37fb96361b37739e3a2ea Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 17:09:39 +0000 Subject: [PATCH 14/15] update Signal intro copy to reflect auto-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's copy says "Check that signal-cli is installed (we'll guide you if not)" but the auto-install PR (#2281) makes that misleading — we don't guide, we just install. Update the intro list to match what will actually happen, and add a "no input needed for any of it" lead so users know to expect a hands-off run. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/signal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 498690f..5f5518d 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -57,9 +57,9 @@ export async function runSignalChannel(displayName: string): Promise Date: Tue, 5 May 2026 20:47:36 +0200 Subject: [PATCH 15/15] fix(setup): pin Baileys to 7.0.0-rc.9 in install-whatsapp scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2259 (Baileys v6→v7) was merged into the channels branch instead of main. PR #2260 was merged into main 28s later assuming v7 was already in place. The v6 pin survived in three sites while the WhatsApp adapter copied from origin/channels at install time was already on the v7 LID API, breaking every fresh migrate-v2.sh run at 2c-install-whatsapp with TS errors on remoteJidAlt/participantAlt/lid-mapping.update. Bumps the pin to 7.0.0-rc.9 (the version v1 has been running on for months) in: - setup/install-whatsapp.sh - setup/add-whatsapp.sh - .claude/skills/add-whatsapp/SKILL.md (install instruction) package.json + pnpm-lock.yaml are not touched here — install-whatsapp.sh mutates them at runtime via pnpm install with the corrected pin. Closes #2283 --- .claude/skills/add-whatsapp/SKILL.md | 2 +- setup/add-whatsapp.sh | 2 +- setup/install-whatsapp.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) mode change 100755 => 100644 setup/add-whatsapp.sh mode change 100755 => 100644 setup/install-whatsapp.sh diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 3f10ce1..232725f 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -57,7 +57,7 @@ groups: () => import('./groups.js'), ### 5. Install the adapter packages (pinned) ```bash -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 ``` ### 6. Build diff --git a/setup/add-whatsapp.sh b/setup/add-whatsapp.sh old mode 100755 new mode 100644 index c7356af..be2dacc --- a/setup/add-whatsapp.sh +++ b/setup/add-whatsapp.sh @@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" # Keep in sync with .claude/skills/add-whatsapp/SKILL.md. -BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16" +BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9" QRCODE_VERSION="qrcode@1.5.4" QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" PINO_VERSION="pino@9.6.0" diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh old mode 100755 new mode 100644 index 1c62d65..f18b87a --- a/setup/install-whatsapp.sh +++ b/setup/install-whatsapp.sh @@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then fi echo "STEP: pnpm-install" -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 echo "STEP: pnpm-build" pnpm run build