diff --git a/nanoclaw.sh b/nanoclaw.sh index 17df82ce1..e94e383fe 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -1,15 +1,18 @@ #!/usr/bin/env bash # -# NanoClaw — scripted end-to-end install. +# NanoClaw — end-to-end setup entry point. # -# Phase 1: bootstrap (Node + pnpm + native module verify). Runs bash-side -# since tsx isn't available until pnpm install completes. -# Phase 2: setup:auto (all remaining steps under clack). +# Runs two parts from the user's perspective as one continuous flow: +# - bash-side: install the basics (Node + pnpm + native modules) under a +# bash-rendered clack-alike spinner. Can't use setup/auto.ts here since +# tsx isn't available until pnpm install completes. +# - hand off to `pnpm run setup:auto`, which renders the rest with +# @clack/prompts. The wordmark is printed once here so setup:auto can +# skip it and the flow reads as a single sequence. # -# Both phases obey the same three-level output contract (see -# docs/setup-flow.md): +# Obeys the three-level output contract (see docs/setup-flow.md): # 1. User-facing — concise status line with elapsed time -# 2. Progression log — logs/setup.log (header + one entry per phase/step) +# 2. Progression log — logs/setup.log (header + one entry per step) # 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output) # # Config via env — passed through unchanged: @@ -91,6 +94,19 @@ use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } gray() { use_ansi && printf '\033[90m%s\033[0m' "$1" || printf '%s' "$1"; } red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; } +bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; } +# brand cyan (≈ #2BB7CE) — truecolor when supported, 16-color cyan fallback. +brand_bold() { + if use_ansi; then + if [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then + printf '\033[1;38;2;43;183;206m%s\033[0m' "$1" + else + printf '\033[1;36m%s\033[0m' "$1" + fi + else + printf '%s' "$1" + fi +} clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; } @@ -105,21 +121,20 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -cat <<'EOF' -═══════════════════════════════════════════════════════════════ - NanoClaw scripted setup -═══════════════════════════════════════════════════════════════ +# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1 +# and skip printing these again, so the flow stays visually continuous. +printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')" -Phase 1 · bootstrap - -EOF - -# ─── phase 1: bootstrap ───────────────────────────────────────────────── +# ─── first step: install the basics (Node + pnpm + native modules) ───── BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" -BOOTSTRAP_LABEL="Bootstrapping Node, pnpm, native modules" +BOOTSTRAP_LABEL="Installing the basics" BOOTSTRAP_START=$(date +%s) +# One-line "why" that teaches a differentiator while the user waits. +printf '%s %s\n' "$(gray '│')" \ + "$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via @@ -151,10 +166,10 @@ rm -f "$BOOTSTRAP_EXIT_FILE" BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) if [ "$BOOTSTRAP_RC" -eq 0 ]; then - spinner_success "Bootstrap complete" "$BOOTSTRAP_DUR" + spinner_success "Basics installed" "$BOOTSTRAP_DUR" write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" else - spinner_failure "Bootstrap failed" "$BOOTSTRAP_DUR" + spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR" write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}" @@ -162,23 +177,19 @@ else echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')" tail -40 "$BOOTSTRAP_RAW" echo - echo "Full raw log: $BOOTSTRAP_RAW" - echo "Progression: $PROGRESS_LOG" + echo "$(dim "Full raw log: $BOOTSTRAP_RAW")" + echo "$(dim "Progression: $PROGRESS_LOG")" exit 1 fi -echo -cat <<'EOF' -Phase 2 · setup:auto +# ─── hand off to setup:auto ──────────────────────────────────────────── -EOF - -# ─── phase 2: clack driver ────────────────────────────────────────────── - -# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts that the progression log has -# already been initialized (header + bootstrap entry), so it should append -# rather than wipe. +# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts to skip the wordmark (we +# already printed it) and to append to the progression log rather than +# wipe it. export NANOCLAW_BOOTSTRAPPED=1 -# exec so signals (Ctrl-C) propagate directly to the child. -exec pnpm run setup:auto +# --silent suppresses pnpm's `> nanoclaw@1.2.52 setup:auto / > tsx setup/auto.ts` +# preamble so the flow continues visually from "Basics installed" straight +# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. +exec pnpm --silent run setup:auto diff --git a/setup/auto.ts b/setup/auto.ts index bb23650cc..a0068bbbd 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -27,7 +27,7 @@ import k from 'kleur'; import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; -import { ensureAnswer, fail, runQuietStep } from './lib/runner.js'; +import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { brandBold, brandChip } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -46,121 +46,116 @@ async function main(): Promise { if (!skip.has('environment')) { const res = await runQuietStep('environment', { - running: 'Checking environment…', - done: 'Environment OK.', + running: 'Checking your system…', + done: 'Your system looks good.', }); - if (!res.ok) fail('environment', 'Environment check failed.'); + if (!res.ok) { + fail( + 'environment', + "Your system doesn't look quite right.", + 'See logs/setup-steps/ for details, then retry.', + ); + } } if (!skip.has('container')) { + p.log.message( + k.dim( + 'Your assistant lives in its own sandbox. It can only see what you explicitly share.', + ), + ); const res = await runQuietStep('container', { - running: 'Building the agent container image…', - done: 'Container image ready.', - failed: 'Container build failed.', + running: 'Preparing the sandbox your assistant runs in…', + done: 'Sandbox ready.', + failed: "Couldn't prepare the sandbox.", }); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'runtime_not_available') { fail( 'container', - 'Docker is not available and could not be started automatically.', - 'Install Docker Desktop or start it manually, then retry.', + "Docker isn't available.", + 'Install Docker Desktop (or start it if already installed), then retry.', ); } if (err === 'docker_group_not_active') { fail( 'container', - 'Docker was just installed but your shell is not yet in the `docker` group.', + "Docker was just installed but your shell doesn't know yet.", 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } fail( 'container', - 'Container build/test failed.', - 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + "Couldn't build the sandbox.", + 'If Docker has a stale cache, try: `docker builder prune -f`, then retry.', ); } maybeReexecUnderSg(); } if (!skip.has('onecli')) { + p.log.message( + k.dim( + 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + ), + ); const res = await runQuietStep('onecli', { - running: 'Installing OneCLI credential vault…', - done: 'OneCLI installed.', + running: "Setting up OneCLI, your agent's vault…", + done: 'OneCLI vault ready.', }); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { fail( 'onecli', - 'OneCLI installed but not on PATH.', + 'OneCLI was installed but your shell needs to refresh to see it.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( 'onecli', - `OneCLI install failed (${err ?? 'unknown'}).`, - 'Check that curl + a writable ~/.local/bin are available, then retry.', + `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, + 'Make sure curl is installed and ~/.local/bin is writable, then retry.', ); } } if (!skip.has('auth')) { - if (anthropicSecretExists()) { - p.log.success('OneCLI already has an Anthropic secret — skipping.'); - setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); - } else { - p.log.step('Registering your Anthropic credential…'); - console.log( - k.dim(' (browser sign-in or paste a token/key — this part is interactive)'), - ); - console.log(); - const start = Date.now(); - const code = await runInheritScript('bash', ['setup/register-claude-token.sh']); - const durationMs = Date.now() - start; - console.log(); - if (code !== 0) { - setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); - fail( - 'auth', - 'Anthropic credential registration failed or was aborted.', - 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', - ); - } - setupLog.step('auth', 'interactive', durationMs, { - METHOD: 'register-claude-token.sh', - }); - p.log.success('Anthropic credential registered with OneCLI.'); - } + await runAuthStep(); } if (!skip.has('mounts')) { const res = await runQuietStep( 'mounts', { - running: 'Writing mount allowlist…', - done: 'Mount allowlist in place.', - skipped: 'Mount allowlist already configured.', + running: "Setting your assistant's access rules…", + done: 'Access rules set.', + skipped: 'Access rules already set.', }, ['--empty'], ); - if (!res.ok) fail('mounts', 'Mount allowlist step failed.'); + if (!res.ok) { + fail('mounts', "Couldn't write access rules."); + } } if (!skip.has('service')) { const res = await runQuietStep('service', { - running: 'Installing the background service…', - done: 'Service installed and running.', + running: 'Starting NanoClaw in the background…', + done: 'NanoClaw is running.', }); if (!res.ok) { fail( 'service', - 'Service install failed.', - 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', + "Couldn't start NanoClaw.", + 'See logs/nanoclaw.error.log for details.', ); } if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { - p.log.warn('Docker group stale in systemd session.'); + p.log.warn( + "NanoClaw's permissions need a tweak before it can reach Docker.", + ); p.log.message( k.dim( ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + @@ -182,16 +177,16 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Wiring the terminal agent…', - done: 'Terminal agent wired (try `pnpm run chat hi`).', + running: 'Setting up your terminal chat…', + done: 'Terminal chat ready. Try `pnpm run chat hi`.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); if (!res.ok) { fail( 'cli-agent', - 'CLI agent wiring failed.', - `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, + "Couldn't set up the terminal chat.", + `You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`, ); } } @@ -201,47 +196,165 @@ async function main(): Promise { if (choice === 'telegram') { await runTelegramChannel(displayName!); } else { - p.log.info('No messaging channel wired — you can add one later with `/add-`.'); + p.log.info( + "No messaging app for now. You can add one later (like Telegram, Slack, or Discord).", + ); } } if (!skip.has('verify')) { const res = await runQuietStep('verify', { - running: 'Verifying the install…', - done: 'Install verified.', - failed: 'Verification found issues.', + running: 'Making sure everything works together…', + done: "Everything's connected.", + failed: 'A few things still need your attention.', }); if (!res.ok) { const notes: string[] = []; if (res.terminal?.fields.CREDENTIALS !== 'configured') { - notes.push('• Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`.'); + notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); } const agentPing = res.terminal?.fields.AGENT_PING; if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { notes.push( - `• CLI agent did not reply (status: ${agentPing}). ` + - 'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.', + "• Your assistant didn't reply to a test message. " + + 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', ); } if (!res.terminal?.fields.CONFIGURED_CHANNELS) { - notes.push('• Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …'); + notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.'); } if (notes.length > 0) { - p.note(notes.join('\n'), 'What’s left'); + p.note(notes.join('\n'), "What's left"); } - p.outro(k.yellow('Scripted steps done — some pieces still need you.')); + p.outro(k.yellow('Almost there. A few things still need your attention.')); return; } } - const nextSteps = [ - `${k.cyan('Chat from the CLI:')} pnpm run chat hi`, - `${k.cyan('Tail host logs:')} tail -f logs/nanoclaw.log`, - `${k.cyan('Open Claude Code:')} claude`, - ].join('\n'); - p.note(nextSteps, 'Next steps'); + const rows: [string, string][] = [ + ['Chat in the terminal:', 'pnpm run chat hi'], + ["See what's happening:", 'tail -f logs/nanoclaw.log'], + ['Open Claude Code:', 'claude'], + ]; + 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'); setupLog.complete(Date.now() - RUN_START); - p.outro(k.green('Setup complete.')); + p.outro(k.green("You're ready! Enjoy NanoClaw.")); +} + +// ─── auth step (select → branch) ──────────────────────────────────────── + +async function runAuthStep(): Promise { + if (anthropicSecretExists()) { + p.log.success('Your Claude account is already connected.'); + setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); + return; + } + + const method = ensureAnswer( + await p.select({ + message: 'How would you like to connect to Claude?', + options: [ + { + value: 'subscription', + label: 'Sign in with my Claude subscription', + hint: 'recommended if you have Pro or Max', + }, + { + value: 'oauth', + label: 'Paste an OAuth token I already have', + hint: 'sk-ant-oat…', + }, + { + value: 'api', + label: 'Paste an Anthropic API key', + hint: 'pay-per-use via console.anthropic.com', + }, + ], + }), + ) as 'subscription' | 'oauth' | 'api'; + setupLog.userInput('auth_method', method); + + if (method === 'subscription') { + await runSubscriptionAuth(); + } else { + await runPasteAuth(method); + } +} + +async function runSubscriptionAuth(): Promise { + p.log.step("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(); + const code = await runInheritScript('bash', [ + 'setup/register-claude-token.sh', + ]); + const durationMs = Date.now() - start; + console.log(); + if (code !== 0) { + setupLog.step('auth', 'failed', durationMs, { + EXIT_CODE: code, + METHOD: 'subscription', + }); + fail( + 'auth', + "Couldn't complete the Claude sign-in.", + 'Re-run setup and try again, or choose a paste option instead.', + ); + } + setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); + p.log.success('Claude account connected.'); +} + +async function runPasteAuth(method: 'oauth' | 'api'): Promise { + const label = method === 'oauth' ? 'OAuth token' : 'API key'; + const prefix = method === 'oauth' ? 'sk-ant-oat' : 'sk-ant-api'; + + const answer = ensureAnswer( + await p.password({ + message: `Paste your ${label}`, + validate: (v) => { + if (!v || !v.trim()) return 'Required'; + if (!v.trim().startsWith(prefix)) { + return `Should start with ${prefix}…`; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + + const res = await runQuietChild( + 'auth', + 'onecli', + [ + 'secrets', 'create', + '--name', 'Anthropic', + '--type', 'anthropic', + '--value', token, + '--host-pattern', 'api.anthropic.com', + ], + { + running: `Saving your ${label} to your OneCLI vault…`, + done: 'Claude account connected.', + }, + { + extraFields: { METHOD: method }, + }, + ); + if (!res.ok) { + fail( + 'auth', + `Couldn't save your ${label} to the vault.`, + 'Make sure OneCLI is running (`onecli version`), then retry.', + ); + } } // ─── prompts owned by the sequencer ──────────────────────────────────── @@ -249,7 +362,7 @@ async function main(): Promise { async function askDisplayName(fallback: string): Promise { const answer = ensureAnswer( await p.text({ - message: 'What should your agents call you?', + message: 'What should your assistant call you?', placeholder: fallback, defaultValue: fallback, }), @@ -262,10 +375,10 @@ async function askDisplayName(fallback: string): Promise { async function askChannelChoice(): Promise<'telegram' | 'skip'> { const choice = ensureAnswer( await p.select({ - message: 'Connect a messaging app so you can chat from your phone?', + message: 'Want to chat with your assistant from your phone?', options: [ - { value: 'telegram', label: 'Telegram', hint: 'recommended' }, - { value: 'skip', label: 'Skip — use the CLI only' }, + { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, + { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); @@ -311,7 +424,7 @@ 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('Docker socket not accessible in current group. Re-executing under `sg docker`.'); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, @@ -323,17 +436,28 @@ function maybeReexecUnderSg(): void { function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; if (isReexec) { - p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); + p.intro( + `${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`, + ); + return; + } + + // When we were called via nanoclaw.sh, the wordmark + subtitle were + // already printed in bash. Just open the clack gutter with a short, + // neutral intro so the flow continues without duplication. + if (isBootstrapped) { + p.intro(k.dim("Let's get you set up.")); return; } console.log(); console.log(` ${wordmark}`); - console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); - p.intro(`${brandChip(' setup:auto ')}`); + console.log(` ${k.dim('Setting up your personal AI assistant')}`); + p.intro(k.dim("Let's get you set up.")); } /** diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index d3e3f89e5..348cd0502 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -43,8 +43,8 @@ export async function runTelegramChannel(displayName: string): Promise { 'bash', ['setup/add-telegram.sh'], { - running: `Installing Telegram adapter and wiring @${botUsername}…`, - done: 'Telegram adapter ready.', + running: `Connecting Telegram to @${botUsername}…`, + done: 'Telegram connected.', }, { env: { TELEGRAM_BOT_TOKEN: token }, @@ -54,8 +54,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!install.ok) { fail( 'telegram-install', - 'Telegram install failed.', - 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', + "Couldn't connect Telegram.", + 'See logs/setup-steps/ for details, then retry setup.', ); } @@ -63,8 +63,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!pair.ok) { fail( 'pair-telegram', - 'Telegram pairing failed.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + "Couldn't pair with Telegram.", + 'Re-run setup to try again.', ); } @@ -73,8 +73,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!platformId || !pairedUserId) { fail( 'pair-telegram', - 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', + 'Pairing completed but came back incomplete.', + 'Re-run setup to try again.', ); } @@ -92,8 +92,8 @@ export async function runTelegramChannel(displayName: string): Promise { '--agent-name', agentName, ], { - running: `Wiring ${agentName} to your Telegram chat…`, - done: `${agentName} is wired — welcome DM incoming.`, + running: `Connecting ${agentName} to your Telegram chat…`, + done: `${agentName} is ready. Check Telegram for a welcome message.`, }, { extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, @@ -102,8 +102,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!init.ok) { fail( 'init-first-agent', - 'Wiring the Telegram agent failed.', - `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName}" --agent-name "${agentName}"\`.`, + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', ); } } @@ -111,24 +111,26 @@ export async function runTelegramChannel(displayName: string): Promise { async function collectTelegramToken(): Promise { p.note( [ - '1. Open Telegram and message @BotFather', - '2. Send: /newbot', - '3. Follow the prompts (name + username ending in "bot")', - '4. Copy the token it gives you (format: :)', + "Your assistant talks to you through a Telegram bot you create.", + "Here's how:", '', - k.dim('Optional, but recommended for groups:'), - k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ' 1. Open Telegram and message @BotFather', + ' 2. Send /newbot and follow the prompts', + ' 3. Copy the token it gives you (it looks like :)', + '', + k.dim('Planning to add your assistant to group chats? In @BotFather:'), + k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'), ].join('\n'), - 'Create a Telegram bot', + 'Set up your Telegram bot', ); const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', validate: (v) => { - if (!v || !v.trim()) return 'Token is required'; + if (!v || !v.trim()) return "Token is required"; if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { - return 'Format looks wrong — expected :'; + return "That doesn't look right. It should be :"; } return undefined; }, @@ -145,7 +147,7 @@ async function collectTelegramToken(): Promise { async function validateTelegramToken(token: string): Promise { const s = p.spinner(); const start = Date.now(); - s.start('Validating token with Telegram…'); + s.start('Checking your bot token…'); try { const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); const data = (await res.json()) as { @@ -156,7 +158,7 @@ async function validateTelegramToken(token: string): Promise { const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.result?.username) { const username = data.result.username; - s.stop(`Bot is @${username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`); setupLog.step('telegram-validate', 'success', Date.now() - start, { BOT_USERNAME: username, BOT_ID: data.result.id ?? '', @@ -164,26 +166,26 @@ async function validateTelegramToken(token: string): Promise { return username; } const reason = data.description ?? 'token rejected by Telegram'; - s.stop(`Telegram rejected the token: ${reason}`, 1); + s.stop(`Telegram didn't accept that token: ${reason}`, 1); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: reason, }); fail( 'telegram-validate', - 'Telegram rejected the token.', - 'Double-check the token (copy it again from @BotFather) and retry.', + "Telegram didn't accept that token.", + 'Copy the token again from @BotFather and try setup once more.', ); } catch (err) { const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Could not reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, }); fail( 'telegram-validate', - 'Telegram API unreachable.', - 'Check your network connection and retry.', + "Couldn't reach Telegram.", + 'Check your internet connection and retry setup.', ); } } @@ -194,7 +196,7 @@ async function runPairTelegram(): Promise< const rawLog = setupLog.stepRawLog('pair-telegram'); const start = Date.now(); const s = p.spinner(); - s.start('Creating pairing code…'); + s.start('Generating a secret code for your bot…'); let spinnerActive = true; const stopSpinner = (msg: string, code?: number) => { @@ -211,15 +213,15 @@ async function runPairTelegram(): Promise< if (block.type === 'PAIR_TELEGRAM_CODE') { const reason = block.fields.REASON ?? 'initial'; if (reason === 'initial') { - stopSpinner('Pairing code ready.'); + stopSpinner('Your secret code is ready.'); } else { - stopSpinner('Previous code invalidated. New code below.'); + stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); - s.start('Waiting for the code from Telegram…'); + p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + s.start('Waiting for you to send the code from Telegram…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { - stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); + stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`); s.start('Waiting for the correct code…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM') { @@ -238,7 +240,7 @@ async function runPairTelegram(): Promise< // sure we don't leave the spinner running. if (spinnerActive) { stopSpinner( - result.ok ? 'Done.' : 'Pairing exited unexpectedly.', + result.ok ? 'Done.' : 'Pairing ended unexpectedly.', result.ok ? 0 : 1, ); if (!result.ok) dumpTranscriptOnFailure(result.transcript); @@ -254,7 +256,7 @@ function formatCodeCard(code: string): string { '', ` ${brandBold(spaced)}`, '', - k.dim(' Send these digits from Telegram to your bot.'), + k.dim(' Send this code to your bot from Telegram.'), ].join('\n'); } @@ -266,7 +268,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your messaging agent be called?', + message: 'What should your assistant be called?', placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index 8bcab734a..e0707bf07 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -1,128 +1,88 @@ #!/usr/bin/env bash set -euo pipefail -# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in -# /bin/bash, but Homebrew users usually have 5.x first on PATH. The readline -# preload is optional — on 3.x we fall back to a plain confirmation prompt. - -# Register an Anthropic credential with OneCLI. Three paths: -# 1) Claude subscription — run `claude setup-token` (browser sign-in) -# and capture the resulting OAuth token. -# 2) Paste an existing sk-ant-oat… OAuth token you already have. -# 3) Paste an Anthropic API key (sk-ant-api…). +# Register a Claude subscription OAuth token with OneCLI — the *only* auth +# path that needs a TTY break in the flow. Paste-based paths (existing +# OAuth token / API key) are handled in-process by setup/auto.ts using +# clack prompts, then onecli secrets create is invoked directly from TS. +# +# Flow: +# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser +# OAuth dance works and its token is captured into a tempfile. +# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture. +# 3. Register it with OneCLI. # # Env overrides: # SECRET_NAME OneCLI secret name (default: Anthropic) # HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) +# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in +# /bin/bash, but Homebrew users usually have 5.x first on PATH. The +# readline preload is optional — on 3.x we fall back to a plain prompt. + SECRET_NAME="${SECRET_NAME:-Anthropic}" HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}" command -v onecli >/dev/null \ || { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; } +command -v claude >/dev/null \ + || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } +command -v script >/dev/null \ + || { echo "script(1) is required for PTY capture." >&2; exit 1; } -TOKEN="" +tmpfile=$(mktemp -t claude-setup-token.XXXXXX) +trap 'rm -f "$tmpfile"' EXIT -capture_via_claude_setup_token() { - command -v claude >/dev/null \ - || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } - command -v script >/dev/null \ - || { echo "script(1) is required for PTY capture." >&2; exit 1; } +cat <<'EOF' +A browser window will open for you to sign in with your Claude account. +When you finish, we'll save the token to your OneCLI vault automatically. - local tmpfile - tmpfile=$(mktemp -t claude-setup-token.XXXXXX) - trap 'rm -f "$tmpfile"' RETURN - - cat <<'EOF' -A browser window will open for sign-in. Token is captured automatically. -Press Enter to run, or edit the command first. +Press Enter to continue, or edit the command first. EOF - local cmd="claude setup-token" - if [[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]; then - # bash 4+: pre-fill the readline buffer so Enter literally submits. - read -r -e -i "$cmd" -p "$ " cmd /dev/null | grep -q util-linux; then - script -q -c "$cmd" "$tmpfile" - else - # BSD script: command is argv after the file, so let it word-split. - # shellcheck disable=SC2086 - script -q "$tmpfile" $cmd - fi +# `script` arg order differs between BSD (macOS) and util-linux. +if script --version 2>/dev/null | grep -q util-linux; then + script -q -c "$cmd" "$tmpfile" +else + # BSD script: command is argv after the file, so let it word-split. + # shellcheck disable=SC2086 + script -q "$tmpfile" $cmd +fi - # Strip ANSI codes + newlines (TTY wraps the token mid-string), then match - # the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. - TOKEN=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ - | tr -d '\n\r' \ - | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ - | tail -1 || true) +# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match +# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. +token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ + | tr -d '\n\r' \ + | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ + | tail -1 || true) - if [[ -z "$TOKEN" ]]; then - local keep - keep=$(mktemp -t claude-setup-token-log.XXXXXX) - cp "$tmpfile" "$keep" - echo >&2 - echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 - exit 1 - fi -} - -prompt_for_pasted() { - local prefix="$1" # "oat" or "api" - local value - echo - echo "Paste your sk-ant-${prefix}… credential and press Enter." - echo "Nothing will appear on the screen as you paste — that's intentional." - echo "Paste once, then just press Enter to submit." - read -r -s -p "> " value &2 - exit 1 - fi - if [[ ! "$value" =~ ^sk-ant-${prefix} ]]; then - echo "Value does not start with sk-ant-${prefix}. Aborting." >&2 - exit 1 - fi - TOKEN="$value" -} - -cat <&2; exit 1 ;; -esac +if [ -z "$token" ]; then + keep=$(mktemp -t claude-setup-token-log.XXXXXX) + cp "$tmpfile" "$keep" + echo >&2 + echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 + exit 1 +fi echo -echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}" -echo "Registering with OneCLI as '${SECRET_NAME}' (host pattern: ${HOST_PATTERN})…" +echo "Got token: ${token:0:16}…${token: -4}" +echo "Saving it to your OneCLI vault as '${SECRET_NAME}' (host: ${HOST_PATTERN})…" onecli secrets create \ --name "$SECRET_NAME" \ --type anthropic \ - --value "$TOKEN" \ + --value "$token" \ --host-pattern "$HOST_PATTERN" echo "Done."