Merge branch 'main' into pr/setup-local-bin-path

This commit is contained in:
Mohamed Khedr
2026-04-30 21:57:55 +01:00
committed by GitHub
47 changed files with 1970 additions and 380 deletions

View File

@@ -47,13 +47,14 @@ import {
} from './lib/setup-config-parse.js';
import { runAdvancedScreen } from './lib/setup-config-screen.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
import { pollHealth } from './onecli.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js';
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent';
@@ -92,17 +93,21 @@ async function main(): Promise<void> {
// Welcome menu — default path or open advanced overrides before any setup
// work begins. Default lands on standard so Enter is the happy path.
const startChoice = ensureAnswer(
await brightSelect<'default' | 'advanced'>({
message: 'How would you like to begin?',
options: [
{ value: 'default', label: 'Standard setup' },
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
],
initialValue: 'default',
}),
) as 'default' | 'advanced';
setupLog.userInput('start_choice', startChoice);
// On sg re-exec, the user already chose — skip straight to standard.
let startChoice: 'default' | 'advanced' = 'default';
if (process.env.NANOCLAW_REEXEC_SG !== '1') {
startChoice = ensureAnswer(
await brightSelect<'default' | 'advanced'>({
message: 'How would you like to begin?',
options: [
{ value: 'default', label: 'Standard setup' },
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
],
initialValue: 'default',
}),
) as 'default' | 'advanced';
setupLog.userInput('start_choice', startChoice);
}
if (startChoice === 'advanced') {
configValues = await runAdvancedScreen(configValues);
applyToEnv(configValues);
@@ -130,11 +135,13 @@ async function main(): Promise<void> {
}
if (!skip.has('container')) {
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
p.log.message(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
brandBody(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
),
),
);
const res = await runWindowedStep('container', {
@@ -169,9 +176,11 @@ async function main(): Promise<void> {
if (!skip.has('onecli')) {
p.log.message(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
brandBody(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
),
),
);
@@ -295,29 +304,39 @@ async function main(): Promise<void> {
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
}
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
p.log.message(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
brandBody(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
),
);
}
}
let displayName: string | undefined;
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
if (needsDisplayName) {
const fallback = process.env.USER?.trim() || 'Operator';
async function resolveDisplayName(): Promise<string> {
if (displayName) return displayName;
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
displayName = preset || (await askDisplayName(fallback));
const existing = detectExistingDisplayName(process.cwd());
const fallback = process.env.USER?.trim() || 'Operator';
displayName = preset || existing || (await askDisplayName(fallback));
return displayName;
}
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
skip.add('cli-agent');
skip.add('first-chat');
}
if (!skip.has('cli-agent')) {
await resolveDisplayName();
const res = await runQuietStep(
'cli-agent',
{
running: 'Bringing your assistant online…',
done: 'Assistant wired up.',
},
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'],
);
if (!res.ok) {
await fail(
@@ -328,16 +347,39 @@ async function main(): Promise<void> {
}
if (!skip.has('first-chat')) {
p.log.message(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
brandBody(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
),
),
);
const ping = await confirmAssistantResponds();
if (ping === 'ok') {
phEmit('first_chat_ready');
const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent');
const cleanupStart = Date.now();
const cleanup = await spawnQuiet(
'pnpm',
['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'],
cleanupRawLog,
);
setupLog.step(
'cleanup-cli-agent',
cleanup.ok ? 'success' : 'failed',
Date.now() - cleanupStart,
{ exit_code: cleanup.exitCode },
cleanupRawLog,
);
if (!cleanup.ok) {
p.log.warn(
brandBody(
`Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`,
),
);
}
const next = ensureAnswer(
await p.select({
await brightSelect<'continue' | 'chat'>({
message: 'What next?',
options: [
{
@@ -353,7 +395,23 @@ async function main(): Promise<void> {
}),
) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next);
if (next === 'chat') await runFirstChat();
if (next === 'chat') {
const terminalAgentName = `${displayName!}'s Terminal`;
const createRes = await runQuietChild(
'create-terminal-agent',
'pnpm',
['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
{ running: `Creating ${terminalAgentName}`, done: `${terminalAgentName} is ready.` },
);
if (!createRes.ok) {
await fail(
'create-terminal-agent',
`Couldn't create ${terminalAgentName}.`,
'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
);
}
await runFirstChat();
}
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
@@ -379,6 +437,9 @@ async function main(): Promise<void> {
let channelChoice: ChannelChoice = 'skip';
if (!skip.has('channel')) {
channelChoice = await askChannelChoice();
if (channelChoice !== 'skip') {
await resolveDisplayName();
}
if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
@@ -395,9 +456,11 @@ async function main(): Promise<void> {
await runIMessageChannel(displayName!);
} else {
p.log.info(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
brandBody(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
),
);
}
@@ -443,7 +506,7 @@ async function main(): Promise<void> {
);
}
if (notes.length > 0) {
p.note(notes.join('\n'), "What's left");
note(notes.join('\n'), "What's left");
}
// "What's left" is a soft failure — we don't abort like fail(), but the
// user is still stuck and a fix is exactly what claude-assist is for.
@@ -475,11 +538,11 @@ async function main(): Promise<void> {
];
const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
p.note(nextSteps, 'Try these');
note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the
// caveat doesn't land after the user's already looked away at their phone.
p.note(
note(
wrapForGutter(
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
6,
@@ -496,7 +559,7 @@ async function main(): Promise<void> {
// that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro.
p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
p.outro(k.green("You're set."));
} else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
@@ -518,10 +581,7 @@ function channelDmLabel(choice: ChannelChoice): string | null {
case 'imessage':
return 'iMessage';
case 'slack':
// Slack install doesn't wire an agent or send a welcome DM — the
// driver prints its own "finish in your Slack app" note. Falling
// through to null avoids a misleading "check your Slack DMs" banner.
return null;
return 'Slack DMs';
default:
return null;
}
@@ -540,18 +600,16 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const s = p.spinner();
const start = Date.now();
const label = 'Waking your assistant…';
s.start(fitToWidth(label, ' (999s)'));
s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await pingCliAgent();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else {
@@ -578,7 +636,7 @@ function renderPingFailureNote(result: PingResult): void {
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
6,
);
p.note(body, 'Skipping the first chat');
note(body, 'Skipping the first chat');
}
/**
@@ -593,7 +651,7 @@ function renderPingFailureNote(result: PingResult): void {
* clearly optional.
*/
async function runFirstChat(): Promise<void> {
p.note(
note(
wrapForGutter(
[
'Your assistant runs in a sandbox on this machine.',
@@ -640,7 +698,7 @@ function sendChatMessage(message: string): Promise<void> {
async function runAuthStep(): Promise<void> {
if (anthropicSecretExists()) {
p.log.success('Your Claude account is already connected.');
p.log.success(brandBody('Your Claude account is already connected.'));
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
return;
}
@@ -688,7 +746,7 @@ async function runAuthStep(): Promise<void> {
}
async function runSubscriptionAuth(): Promise<void> {
p.log.step('Opening the Claude sign-in flow…');
p.log.step(brandBody('Opening the Claude sign-in flow…'));
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
console.log();
const start = Date.now();
@@ -707,7 +765,7 @@ async function runSubscriptionAuth(): Promise<void> {
);
}
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
p.log.success('Claude account connected.');
p.log.success(brandBody('Claude account connected.'));
}
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
@@ -717,6 +775,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
const answer = ensureAnswer(
await p.password({
message: `Paste your ${label}`,
clearOnError: true,
validate: (v) => {
if (!v || !v.trim()) return 'Required';
if (!v.trim().startsWith(prefix)) {
@@ -930,9 +989,11 @@ async function runTimezoneStep(): Promise<void> {
tz = await resolveTimezoneViaClaude(raw);
} else {
p.log.warn(
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
brandBody(
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
),
),
);
}
@@ -975,7 +1036,7 @@ async function runTimezoneStep(): Promise<void> {
async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant call you?',
message: `What should your assistant call ${accentGreen('you')}?`,
placeholder: fallback,
defaultValue: fallback,
}),
@@ -1105,10 +1166,12 @@ function maybeReexecUnderSg(): void {
if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) },
});
process.exit(res.status ?? 1);
}

View File

@@ -28,9 +28,11 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
@@ -155,7 +157,7 @@ async function askHasBotToken(): Promise<boolean> {
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
p.note(
note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
@@ -163,9 +165,8 @@ async function walkThroughBotCreation(): Promise<void> {
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
@@ -184,7 +185,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void {
// to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one.
if (hasExistingBot) {
p.note(
note(
[
"Where to find your bot token:",
'',
@@ -216,16 +217,15 @@ async function walkThroughServerCreation(): Promise<void> {
// the web client and rely on the + button being visible. The steps below
// are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me';
p.note(
note(
[
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
'',
' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
@@ -239,9 +239,22 @@ async function walkThroughServerCreation(): Promise<void> {
}
async function collectDiscordToken(): Promise<string> {
const existing = readEnvKey('DISCORD_BOT_TOKEN');
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('discord_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
@@ -275,9 +288,8 @@ async function validateDiscordToken(token: string): Promise<string> {
username?: string;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
@@ -295,8 +307,7 @@ async function validateDiscordToken(token: string): Promise<string> {
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -324,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
team?: unknown;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
@@ -337,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
@@ -355,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
owner,
};
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
@@ -385,14 +394,14 @@ async function resolveOwnerUserId(
}
} else {
p.log.info(
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
p.note(
note(
[
"To get your Discord user ID:",
'',
@@ -430,15 +439,14 @@ async function promptInviteBot(
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
p.note(
note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Add bot to a server',
);
await confirmThenOpen(url, 'Press Enter to open the invite page');
@@ -465,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
@@ -478,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
@@ -506,7 +512,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -36,7 +36,8 @@ import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -189,7 +190,7 @@ async function walkThroughFullDiskAccess(): Promise<void> {
}
const nodeDir = path.dirname(nodePath);
p.note(
note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
@@ -222,7 +223,20 @@ async function walkThroughFullDiskAccess(): Promise<void> {
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
p.note(
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
const existingKey = readEnvKey('IMESSAGE_API_KEY');
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('imessage_remote_creds', 'reused-existing');
return { serverUrl: existingUrl, apiKey: existingKey };
}
}
note(
[
"Photon is a separate service that owns an iMessage account and",
"exposes it over HTTP. NanoClaw will talk to it via its API.",
@@ -250,6 +264,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
clearOnError: true,
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
@@ -264,7 +279,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
}
async function askOperatorHandle(): Promise<string> {
p.note(
note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
@@ -303,7 +318,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -44,6 +44,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise<void> {
if (!probe.error && probe.status === 0) return;
if (process.platform === 'darwin') {
p.note(
note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
@@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise<void> {
'signal-cli not found',
);
} else {
p.note(
note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
@@ -323,8 +324,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
@@ -346,7 +346,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -1,24 +1,23 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` walks the operator from a bare Slack
* workspace through a running bot, then stops before wiring an agent:
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
*
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test → resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Print the post-install checklist: set the public webhook URL in
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
* then `/manage-channels` to wire an agent.
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
*
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
* Slack needs a public Event Subscriptions URL for inbound events, and
* opening an unsolicited DM would need `im:write` scope we don't force
* the SKILL.md to require. Shipping a honest "here's what's left" note
* is better than a welcome DM the user won't receive until they
* configure the webhook anyway.
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
* greeting in Slack immediately; inbound replies require webhooks, so the
* post-install note covers that.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
@@ -26,12 +25,15 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo {
teamName: string;
@@ -40,10 +42,7 @@ interface WorkspaceInfo {
botUserId: string;
}
// displayName is reserved for when we start wiring the first agent here.
// Kept to match the `run<X>Channel(displayName)` signature every other
// channel driver uses, so auto.ts can dispatch without a branch.
export async function runSlackChannel(_displayName: string): Promise<void> {
export async function runSlackChannel(displayName: string): Promise<void> {
await walkThroughAppCreation();
const token = await collectBotToken();
@@ -78,26 +77,67 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
);
}
const ownerUserId = await collectSlackUserId();
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `slack:${dmChannelId}`;
const role = await askOperatorRole('Slack');
setupLog.userInput('slack_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'slack',
'--user-id', `slack:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Wiring ${agentName} to your Slack DMs…`,
done: 'Agent wired.',
},
{
extraFields: {
CHANNEL: 'slack',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/init-first-agent` in Claude Code.',
);
}
showPostInstallChecklist(info);
}
async function walkThroughAppCreation(): Promise<void> {
p.note(
note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' chat:write, channels:history, groups:history, im:history,',
' channels:read, groups:read, users:read, reactions:write',
' chat:write, im:write, channels:history, groups:history,',
' im:history, channels:read, groups:read, users:read,',
' reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
formatNoteLink(SLACK_APPS_URL),
].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');
@@ -111,9 +151,22 @@ async function walkThroughAppCreation(): Promise<void> {
}
async function collectBotToken(): Promise<string> {
const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_bot_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
@@ -132,9 +185,22 @@ async function collectBotToken(): Promise<string> {
}
async function collectSigningSecret(): Promise<string> {
const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_signing_secret', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
@@ -175,10 +241,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
user_id?: string;
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
@@ -207,8 +272,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -221,26 +285,133 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
}
}
async function collectSlackUserId(): Promise<string> {
note(
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (top right)',
' 2. Click "Profile"',
' 3. Click the three dots (⋯) → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Slack member ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Member ID is required';
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
return "That doesn't look like a Slack member ID (starts with U)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('slack_user_id', id);
return id;
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${SLACK_API}/conversations.open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const data = (await res.json()) as {
ok?: boolean;
channel?: { id?: string };
error?: string;
};
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
return data.channel.id;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
if (reason === 'missing_scope') {
await fail(
'slack-open-dm',
"Your Slack app is missing the im:write scope.",
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
);
}
await fail(
'slack-open-dm',
"Couldn't open a DM channel with you.",
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-open-dm',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
function showPostInstallChecklist(info: WorkspaceInfo): void {
p.note(
note(
wrapForGutter(
[
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
`Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
'',
' 1. A public URL so Slack can deliver events.',
' NanoClaw serves a webhook on port 3000 by default — expose it',
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • Save, then reinstall the app when Slack prompts',
' • Save Changes',
'',
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
' messaging group. Then run `/manage-channels` in `claude` to',
' wire an agent to it.',
' 3. In your Slack app → Interactivity & Shortcuts:',
' • Toggle "Interactivity" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
].join('\n'),
6,
),

View File

@@ -40,7 +40,9 @@ import {
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
@@ -59,6 +61,28 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
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({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
initialValue: true,
}));
if (reuse) {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);
completed.push('Adapter installed and service restarted (reused existing credentials).');
await finishWithHandoff(collected, completed);
return;
}
}
printIntro();
await confirmPrereqs({ collected, completed });
@@ -79,7 +103,7 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
// ─── step: intro / prereqs ──────────────────────────────────────────────
function printIntro(): void {
p.note(
note(
[
'Setting up Teams is more involved than the other channels — about',
'7 steps across the Azure portal and Teams admin.',
@@ -93,7 +117,7 @@ function printIntro(): void {
}
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
p.note(
note(
[
'Before we start, confirm you have:',
'',
@@ -119,7 +143,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
// ─── step: public URL ──────────────────────────────────────────────────
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
p.note(
note(
[
"Azure Bot Service delivers messages to an HTTPS endpoint you",
"control. The endpoint needs to reach this machine's webhook",
@@ -175,7 +199,7 @@ async function stepAppRegistration(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
p.note(
note(
[
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
'2. Name it (e.g. "NanoClaw")',
@@ -259,7 +283,7 @@ async function stepClientSecret(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
p.note(
note(
[
`1. In your app registration, open "Certificates & secrets"`,
'2. Click "New client secret"',
@@ -276,6 +300,7 @@ async function stepClientSecret(args: {
const answer = ensureAnswer(
await p.password({
message: 'Paste the client secret Value',
clearOnError: true,
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
@@ -328,7 +353,7 @@ async function stepAzureBot(args: {
` --appid ${args.collected.appId} \\\n` +
` ${tenantFlag}--endpoint "${endpoint}"`;
p.note(
note(
[
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
'',
@@ -365,7 +390,7 @@ async function stepEnableTeamsChannel(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
p.note(
note(
[
'1. Open your Azure Bot resource → Channels',
'2. Click Microsoft Teams → Accept terms → Apply',
@@ -435,7 +460,7 @@ async function stepSideload(args: {
completed: string[];
zipPath: string;
}): Promise<void> {
p.note(
note(
[
'1. Open Microsoft Teams',
'2. Go to Apps → Manage your apps → Upload an app',
@@ -501,7 +526,7 @@ async function finishWithHandoff(
collected: Collected,
completed: string[],
): Promise<void> {
p.note(
note(
[
'The Teams adapter is live and the service is running.',
'',
@@ -530,7 +555,7 @@ async function finishWithHandoff(
);
if (choice === 'self') {
p.note(
note(
[
' 1. Find your bot in Teams (search by name, or via the sideloaded',
' app) and send it a message ("hi" is fine)',

View File

@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import {
type Block,
@@ -33,7 +33,8 @@ import {
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { brandBold } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -47,12 +48,11 @@ 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}`;
p.note(
note(
[
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'',
k.dim(botUrl),
].join('\n'),
formatNoteLink(botUrl),
].filter((line): line is string => line !== null).join('\n'),
'Open Telegram',
);
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
@@ -132,7 +132,19 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
}
async function collectTelegramToken(): Promise<string> {
p.note(
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('telegram_token', 'reused-existing');
return existing;
}
}
note(
[
"Your assistant talks to you through a Telegram bot you create.",
"Here's how:",
@@ -150,6 +162,7 @@ async function collectTelegramToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
if (!v || !v.trim()) return "Token is required";
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
@@ -178,10 +191,9 @@ async function validateTelegramToken(token: string): Promise<string> {
result?: { username?: string; id?: number };
description?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.result?.username) {
const username = data.result.username;
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
@@ -199,8 +211,7 @@ async function validateTelegramToken(token: string): Promise<string> {
'Copy the token again from @BotFather and try setup once more.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -240,12 +251,12 @@ async function runPairTelegram(): Promise<
} else {
stopSpinner("Old code expired. Here's a fresh one.");
}
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start('Waiting for you to send the code from Telegram…');
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
s.start('Waiting for the correct code…');
s.start(fitToWidth('Waiting for the correct code…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
@@ -291,7 +302,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -46,7 +46,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { brandBold } from '../lib/theme.js';
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
@@ -171,7 +171,7 @@ async function askAuthMethod(): Promise<AuthMethod> {
}
async function askPhoneNumber(): Promise<string> {
p.note(
note(
[
"Enter your phone number the way WhatsApp expects it:",
'',
@@ -249,7 +249,7 @@ async function runWhatsAppAuth(
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
const code = block.fields.CODE ?? '????';
stopSpinner('Your pairing code is ready.');
p.note(formatPairingCard(code), 'Pairing code');
note(formatPairingCard(code), 'Pairing code');
s.start('Waiting for you to enter the code…');
spinnerActive = true;
} else if (block.type === 'WHATSAPP_AUTH') {
@@ -267,7 +267,7 @@ async function runWhatsAppAuth(
if (spinnerActive) {
stopSpinner('WhatsApp linked.');
} else {
p.log.success('WhatsApp linked.');
p.log.success(brandBody('WhatsApp linked.'));
}
} else if (status === 'failed') {
if (qrLinesPrinted > 0) {
@@ -379,8 +379,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to reconnect before init-first-agent's
// welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
@@ -395,7 +394,7 @@ async function restartService(): Promise<void> {
}
async function askChatPhone(authedPhone: string): Promise<string> {
p.note(
note(
[
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
'',
@@ -462,7 +461,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -8,6 +8,7 @@
* Args:
* --display-name <name> (required) operator's display name
* --agent-name <name> (optional) agent persona name, defaults to display-name
* --folder <name> (optional) explicit folder name, defaults to cli-with-<normalized-display-name>
*/
import { execFileSync } from 'child_process';
import path from 'path';
@@ -18,9 +19,11 @@ import { emitStatus } from './status.js';
function parseArgs(args: string[]): {
displayName: string;
agentName?: string;
folder?: string;
} {
let displayName: string | undefined;
let agentName: string | undefined;
let folder: string | undefined;
for (let i = 0; i < args.length; i++) {
const key = args[i];
@@ -34,6 +37,10 @@ function parseArgs(args: string[]): {
agentName = val;
i++;
break;
case '--folder':
folder = val;
i++;
break;
}
}
@@ -46,17 +53,18 @@ function parseArgs(args: string[]): {
process.exit(2);
}
return { displayName, agentName };
return { displayName, agentName, folder };
}
export async function run(args: string[]): Promise<void> {
const { displayName, agentName } = parseArgs(args);
const { displayName, agentName, folder } = parseArgs(args);
const projectRoot = process.cwd();
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
if (agentName) scriptArgs.push('--agent-name', agentName);
if (folder) scriptArgs.push('--folder', folder);
log.info('Invoking init-cli-agent', { displayName, agentName });

View File

@@ -127,11 +127,22 @@ export async function run(args: string[]): Promise<void> {
}
// Socket is unreachable due to group perms — current shell's supplementary
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
// or a prior install) doesn't affect us until next login. Re-exec this
// step under `sg docker` so the child picks up docker as its primary
// group and can talk to /var/run/docker.sock without a logout.
// groups are fixed at login, so `usermod -aG docker` doesn't affect us
// until next login. Ensure the user is in the docker group (install-docker.sh
// does this on fresh installs, but skips when Docker is already present),
// then re-exec under `sg docker` so the child picks up docker as its
// primary group and can talk to /var/run/docker.sock without a logout.
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
// Ensure the current user is in the docker group — without this,
// sg will ask for the (typically unset) group password and fail.
const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' });
if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) {
log.info('Adding current user to docker group');
spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], {
stdio: 'inherit',
});
}
log.info('Re-executing container step under `sg docker`');
const res = spawnSync(
'sg',

View File

@@ -11,6 +11,48 @@ import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
/**
* Read a single key from `.env` on disk (not process.env).
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
*/
export function readEnvKey(key: string, projectRoot?: string): string | null {
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return null;
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 1) continue;
if (trimmed.slice(0, eq) === key) {
return trimmed.slice(eq + 1).trim() || null;
}
}
return null;
}
export function detectExistingDisplayName(projectRoot: string): string | null {
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return null;
let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`)
.get() as { display_name: string } | undefined;
return row?.display_name?.trim() || null;
} catch {
return null;
} finally {
db?.close();
}
}
export function detectRegisteredGroups(projectRoot: string): boolean {
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
return true;

View File

@@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core';
import { isCancel } from '@clack/prompts';
import { styleText } from 'node:util';
import { brandBody } from './theme.js';
const BULLET_ACTIVE = '●';
const BULLET_INACTIVE = '○';
const BAR = '│';
@@ -95,7 +97,7 @@ export function brightSelect<T>(
const shown =
st === 'cancel'
? styleText(['strikethrough', 'dim'], selected)
: styleText('dim', selected);
: styleText('dim', brandBody(selected));
lines.push(`${grayBar} ${shown}`);
return lines.join('\n');
}
@@ -104,11 +106,12 @@ export function brightSelect<T>(
options.forEach((opt, idx) => {
const label = opt.label ?? String(opt.value);
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
const marker =
idx === cursor
? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE);
lines.push(`${bar} ${marker} ${label}${hint}`);
const isActive = idx === cursor;
const marker = isActive
? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE);
const shownLabel = isActive ? brandBody(label) : label;
lines.push(`${bar} ${marker} ${shownLabel}${hint}`);
});
lines.push(styleText(color, CAP_BOT));
return lines.join('\n');

View File

@@ -9,12 +9,19 @@
* `confirmThenOpen` pauses for the operator before triggering the open —
* the browser tends to steal focus when it pops, and a split-second
* "wait what just happened" moment is worse than letting the user hit
* Enter when they're ready.
* Enter when they're ready. On headless devices (no graphical session
* available) it skips both the prompt and the open: there's no browser
* to launch, the surrounding `note(...)` already shows the URL for
* copy-paste on another device, and the next prompt in the channel
* flow ("Got your bot token?" etc.) provides the natural completion
* confirmation.
*/
import { spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { isHeadless } from '../platform.js';
import { ensureAnswer } from './runner.js';
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
}
}
/**
* Format a URL for inclusion in a setup `note(...)` card. On
* headless devices we surface the URL inside the card with a
* "Get started:" label at full strength — copy-pasting onto
* another device is the actual action, not an incidental
* reference. The leading `\n` acts as a visual separator from
* the body steps above; callers `.filter(line => line !== null)`
* before joining, so on GUI we drop the line entirely (and the
* URL ends up below the next-step confirm prompt as a "if
* browser does not appear, please visit" fallback — see
* `confirmThenOpen`).
*/
export function formatNoteLink(url: string): string | null {
if (isHeadless()) return `\nGet started: ${url}`;
return null;
}
/**
* Gate a browser-open on a confirm so the user is ready for their browser
* to take focus. Proceeds on cancel as well — the user can always copy the
* URL from the note that precedes the prompt.
* to take focus. Proceeds on cancel as well. On headless devices both the
* prompt and the open are skipped — the URL is already surfaced inside
* the surrounding note (via `formatNoteLink`).
*
* On GUI devices the confirm message includes the fallback URL on the
* lines below the action ("If browser does not appear, please visit:
* <url>" in dim) so the user has a copy-paste path right next to the
* action button without needing to scroll back up to the card.
*/
export async function confirmThenOpen(
url: string,
message = 'Press Enter to open your browser',
): Promise<void> {
if (isHeadless()) return;
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
ensureAnswer(
await p.confirm({
message,
message: `${message}${fallback}`,
initialValue: true,
}),
);

View File

@@ -2,8 +2,11 @@
* Offer Claude-assisted debugging when a setup step fails.
*
* Flow:
* 1. Check `claude` is on PATH and has a working credential. If not,
* silently skip — pre-auth failures can't use this path.
* 1. Check `claude` is on PATH — if not, offer to install it via
* setup/install-claude.sh. Then check auth via `claude auth status`
* — if not signed in, offer to run `claude setup-token` (browser
* OAuth with code-paste fallback for headless/remote systems).
* If either is declined or fails, silently skip.
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
* 3. Build a minimal prompt: the one-paragraph situation, the failing
* step's name/message/hint, and a short list of *file references*
@@ -16,15 +19,16 @@
*
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
*/
import { execSync, spawn } from 'child_process';
import { execSync, spawn, spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { ensureAnswer } from './runner.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
export interface AssistContext {
stepName: string;
@@ -90,7 +94,7 @@ export async function offerClaudeAssist(
projectRoot: string = process.cwd(),
): Promise<boolean> {
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
if (!isClaudeUsable()) return false;
if (!(await ensureClaudeReady(projectRoot))) return false;
const want = ensureAnswer(
await p.confirm({
@@ -106,12 +110,12 @@ export async function offerClaudeAssist(
const parsed = parseResponse(response);
if (!parsed) {
p.log.warn("Claude responded but I couldn't parse a command out of it.");
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
p.log.message(k.dim(response.trim().slice(0, 500)));
return false;
}
p.note(
note(
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
"Claude's suggestion",
);
@@ -128,15 +132,101 @@ export async function offerClaudeAssist(
return true;
}
function isClaudeUsable(): boolean {
function isClaudeInstalled(): boolean {
try {
execSync('command -v claude', { stdio: 'ignore' });
return true;
} catch {
return false;
}
// Availability without auth is half the story; a real query will still
// fail if the token isn't registered. We try first and surface the error
// rather than pre-checking auth with a separate round trip.
}
function isClaudeAuthenticated(): boolean {
try {
execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 });
return true;
} catch {
return false;
}
}
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
if (!isClaudeInstalled()) {
const install = ensureAnswer(
await p.confirm({
message:
'Claude CLI is needed to diagnose this. Install it now?',
initialValue: true,
}),
);
if (!install) return false;
const code = spawnSync('bash', ['setup/install-claude.sh'], {
cwd: projectRoot,
stdio: 'inherit',
}).status;
if (code !== 0 || !isClaudeInstalled()) {
p.log.error("Couldn't install the Claude CLI.");
return false;
}
p.log.success('Claude CLI installed.');
}
if (!isClaudeAuthenticated()) {
const auth = ensureAnswer(
await p.confirm({
message:
"Claude CLI isn't signed in. Sign in now? (a browser will open)",
initialValue: true,
}),
);
if (!auth) return false;
// setup-token has an interactive TUI; reset terminal to cooked mode
// so its prompts render correctly after clack's raw-mode prompts.
spawnSync('stty', ['sane'], { stdio: 'inherit' });
// Run under script(1) to capture the OAuth token from PTY output
// while preserving interactive TTY for the browser OAuth flow.
// Same approach as register-claude-token.sh, but we set the env var
// instead of writing to OneCLI.
const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`);
try {
const isUtilLinux = (() => {
try {
return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux');
} catch { return false; }
})();
const scriptArgs = isUtilLinux
? ['-q', '-c', 'claude setup-token', tmpfile]
: ['-q', tmpfile, 'claude', 'setup-token'];
spawnSync('script', scriptArgs, {
cwd: projectRoot,
stdio: 'inherit',
});
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
const raw = fs.readFileSync(tmpfile, 'utf-8');
const stripped = raw
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\n\r]/g, '');
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
if (matches) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
}
}
} finally {
try { fs.unlinkSync(tmpfile); } catch {}
}
if (!isClaudeAuthenticated()) {
p.log.error("Couldn't complete Claude sign-in.");
return false;
}
p.log.success('Claude CLI signed in.');
}
return true;
}
@@ -205,9 +295,8 @@ async function queryClaudeUnderSpinner(
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
const elapsed = Math.round((Date.now() - start) / 1000);
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
const header = fitToWidth('Asking Claude to diagnose…', suffix);
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
@@ -265,10 +354,9 @@ async function queryClaudeUnderSpinner(
clearBlock();
out.write(SHOW_CURSOR);
process.off('exit', restoreCursorOnExit);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (kind === 'ok') {
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
resolve(payload);
} else {
p.log.error(

View File

@@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { brandBody, note } from './theme.js';
export interface HandoffContext {
/** Channel this handoff is happening in (e.g., 'teams'). */
channel: string;
@@ -62,14 +64,14 @@ export interface HandoffContext {
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
if (!isClaudeUsable()) {
p.log.warn(
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
);
return false;
}
const systemPrompt = buildSystemPrompt(ctx);
p.note(
note(
[
"I'm handing you off to Claude in interactive mode.",
"It has the context of where you are in setup.",
@@ -91,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success("Back from Claude. Let's continue.");
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
child.on('error', () => {

View File

@@ -20,7 +20,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
export type Fields = Record<string, string>;
export type Block = { type: string; fields: Fields };
@@ -307,18 +307,16 @@ async function runUnderSpinner<
): Promise<T> {
const s = p.spinner();
const start = Date.now();
s.start(fitToWidth(labels.running, ' (999s)'));
s.start(fitToWidth(labels.running, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await work();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
@@ -390,7 +388,7 @@ export async function fail(
const skipList = [
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
].join(',');
p.log.step(`Retrying from ${stepName}`);
p.log.step(brandBody(`Retrying from ${stepName}`));
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_SKIP: skipList },

View File

@@ -115,7 +115,7 @@ async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
};
const ans = ensureAnswer(
e.secret
? await p.password({ message: e.label, validate })
? await p.password({ message: e.label, clearOnError: true, validate })
: await p.text({
message: e.label,
placeholder: e.placeholder ?? e.default,

View File

@@ -11,6 +11,7 @@
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
* - Otherwise → kleur's 16-color cyan (closest fallback)
*/
import * as p from '@clack/prompts';
import k from 'kleur';
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -38,6 +39,57 @@ export function brandChip(s: string): string {
return k.bgCyan(k.black(k.bold(s)));
}
/**
* Accent green (#3fba50) for emphasizing a single word inside prompt
* messages — currently the "you" in "What should your assistant call
* you?" so the operator parses at a glance who the question is about.
* Same TTY/NO_COLOR/truecolor gating as the rest of the palette.
*/
export function accentGreen(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`;
return k.green(s);
}
/**
* Format an elapsed-time duration (in milliseconds) for the spinner
* suffixes setup writes everywhere. Sub-minute durations stay in plain
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
* so live spinner output doesn't change shape at every whole minute.
*/
export function fmtDuration(ms: number): string {
const totalSec = Math.round(ms / 1000);
if (totalSec < 60) return `${totalSec}s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}m ${s}s`;
}
/**
* Brand body color for setup-flow prose. Used for card bodies (via the
* `note()` formatter) and `p.log.*` body arguments — anywhere the
* previous "dim" treatment was making prose hard to read or washing
* out embedded brand emphasis.
*
* Multi-line input is colored line-by-line so embedded line breaks
* don't bleed the SGR sequence across clack's gutter prefix.
*/
export function brandBody(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return s
.split('\n')
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
.join('\n');
}
return s
.split('\n')
.map((line) => (line.length > 0 ? k.cyan(line) : line))
.join('\n');
}
/**
* Wrap text so it fits inside clack's gutter without the terminal's soft
* wrap breaking the `│ …` bar on long lines. Works on a single string with
@@ -68,6 +120,16 @@ export function dimWrap(text: string, gutter: number): string {
return wrapForGutter(text, gutter);
}
/**
* Wrap clack's `p.note` so card bodies render in the brand body color
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
* on each line individually, so `brandBody` colors each line cleanly
* without bleeding across the gutter prefix.
*/
export function note(message: string, title?: string): void {
p.note(message, title, { format: brandBody });
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(s: string): number {

View File

@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { isValidTimezone } from '../../src/timezone.js';
import { fitToWidth } from './theme.js';
import { fitToWidth, fmtDuration } from './theme.js';
export function claudeCliAvailable(): boolean {
try {
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
const s = p.spinner();
const start = Date.now();
const label = 'Looking up that timezone…';
s.start(fitToWidth(label, ' (999s)'));
s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const reply = await queryClaude(prompt);
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
const resolved = reply ? extractTimezone(reply) : null;
if (resolved) {

View File

@@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
import * as setupLog from '../logs.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth } from './theme.js';
const WINDOW_SIZE = 3;
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
@@ -169,7 +169,7 @@ async function runUnderWindow(
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
@@ -185,7 +185,7 @@ async function handleStall(
): Promise<void> {
render.pauseRender();
p.log.warn(
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`),
);
phEmit('step_stalled', { step: stepName });