Merge remote-tracking branch 'origin/main' into nc-cli

This commit is contained in:
gavrielc
2026-05-06 00:46:53 +03:00
25 changed files with 585 additions and 124 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="141k tokens, 70% of context window">
<title>141k tokens, 70% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="141k tokens, 71% of context window">
<title>141k tokens, 71% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Executable → Regular
+1 -1
View File
@@ -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"
+39 -28
View File
@@ -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<void> {
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') {
result = await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
result = await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
result = await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
result = await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
result = await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
result = 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;
}
}
+8 -4
View File
@@ -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<void> {
const hasBot = await askHasBotToken();
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
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<void> {
}
}
async function askHasBotToken(): Promise<boolean> {
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<void> {
+30 -24
View File
@@ -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<void> {
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
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<void> {
}
}
async function askMode(isMac: boolean): Promise<Mode> {
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
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<Mode>({
await brightSelect<Mode | 'back'>({
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;
}
+76 -16
View File
@@ -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<void> {
export async function runSignalChannel(displayName: string): Promise<ChannelFlowResult> {
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 — no input needed for any of it:",
'',
' 1. Set up signal-cli (auto-installs if missing)',
' 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(
@@ -134,42 +162,74 @@ export async function runSignalChannel(displayName: string): Promise<void> {
async function ensureSignalCli(): Promise<void> {
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.',
);
}
+23 -5
View File
@@ -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<void> {
await walkThroughAppCreation();
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();
@@ -121,7 +125,7 @@ export async function runSlackChannel(displayName: string): Promise<void> {
showPostInstallChecklist(info);
}
async function walkThroughAppCreation(): Promise<void> {
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<void> {
].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<void> {
initialValue: true,
}),
);
return 'continue';
}
async function collectBotToken(): Promise<string> {
+43 -12
View File
@@ -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<void> {
export async function runTeamsChannel(_displayName: string): Promise<ChannelFlowResult> {
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<void> {
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<void> {
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 ──────────────────────────────────────────────────
+62 -14
View File
@@ -21,7 +21,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 { isHeadless } from '../platform.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import {
type Block,
@@ -38,8 +41,10 @@ import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/th
const DEFAULT_AGENT_NAME = 'Nano';
export async function runTelegramChannel(displayName: string): Promise<void> {
const token = await collectTelegramToken();
export async function runTelegramChannel(displayName: string): Promise<ChannelFlowResult> {
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
@@ -48,14 +53,37 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
// 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',
@@ -131,17 +159,24 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
}
}
async function collectTelegramToken(): Promise<string> {
async function collectTelegramToken(): Promise<string | 'back'> {
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 +194,19 @@ async function collectTelegramToken(): Promise<string> {
'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',
+12 -6
View File
@@ -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<void> {
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
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<void> {
}
}
async function askAuthMethod(): Promise<AuthMethod> {
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to authenticate with WhatsApp?',
@@ -163,10 +165,14 @@ async function askAuthMethod(): Promise<AuthMethod> {
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;
}
@@ -312,7 +318,7 @@ async function renderQr(qr: string): Promise<string[]> {
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 +334,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');
+78
View File
@@ -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
Executable → Regular
+1 -1
View File
@@ -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
+17
View File
@@ -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;
+128 -1
View File
@@ -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,20 @@ function stubAdapter(partial: Partial<Adapter>): 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<RawMessage<unknown>> => {
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 +92,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');
});
});
+55
View File
@@ -12,6 +12,8 @@ import {
CardText,
Actions,
Button,
LinkButton,
type CardChild,
type Adapter,
type ConcurrencyStrategy,
type Message as ChatMessage,
@@ -399,6 +401,59 @@ 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<string, unknown>;
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<string, unknown>).text === 'string'
) {
cardChildren.push(CardText((child as Record<string, string>).text));
}
}
}
if (Array.isArray(cardSpec.actions)) {
const linkButtons = (cardSpec.actions as Array<Record<string, unknown>>)
.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;