feat(setup): rewrite copy for first-time users + split auth flow
Content pass: every user-facing line is rewritten from the perspective
of someone trying NanoClaw for the first time. Phase labels and devops
framing are gone. Examples:
"Environment OK" → "Your system looks good."
"Container image ready" → "Sandbox ready."
"OneCLI installed" → "OneCLI vault ready."
"Anthropic credential" → "Claude account"
"Mount allowlist in place" → "Access rules set."
"Service installed/running" → "NanoClaw is running."
"Wiring the terminal agent" → "Setting up your terminal chat…"
"Setup complete" → "You're ready! Enjoy NanoClaw."
Long-running steps get a one-sentence "why" that teaches a NanoClaw
differentiator while the user waits:
bootstrap → "NanoClaw is small and runs entirely on your machine.
Yours to modify."
container → "Your assistant lives in its own sandbox. It can only
see what you explicitly share."
onecli → "Your assistant never gets your API keys directly. The
vault adds them to approved requests as they leave the
sandbox."
OneCLI is now named explicitly and framed as "your agent's vault" in
the install step, the paste-auth save step, the subscription-auth
banner, and their associated failure hints.
Auth split (option b: explicit step name on fail): the auth-method
choice moves from the bash menu in register-claude-token.sh into a
clack select. Only the subscription path still breaks out to the
interactive TTY for `claude setup-token`; paste-based OAuth tokens and
API keys stay in clack via p.password() and register directly via
`onecli secrets create`. register-claude-token.sh is scoped down to
the subscription flow only.
nanoclaw.sh: dropped the "Phase 1 / Phase 2" labels. The wordmark and
subtitle now print bash-side so setup:auto skips repeating them and
the flow reads as one continuous sequence. Bootstrap label is
"Installing the basics" with a dim gutter-line "why" preamble. pnpm's
`> nanoclaw@X setup:auto` preamble is suppressed via --silent.
Em-dash pass on user-facing copy: every em-dash that functions as an
em-dash in a user-visible string is replaced with period, semicolon,
comma, or parens. Code comments and JSDoc are untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
nanoclaw.sh
77
nanoclaw.sh
@@ -1,15 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# Runs two parts from the user's perspective as one continuous flow:
|
||||||
# since tsx isn't available until pnpm install completes.
|
# - bash-side: install the basics (Node + pnpm + native modules) under a
|
||||||
# Phase 2: setup:auto (all remaining steps under clack).
|
# 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
|
# Obeys the three-level output contract (see docs/setup-flow.md):
|
||||||
# docs/setup-flow.md):
|
|
||||||
# 1. User-facing — concise status line with elapsed time
|
# 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)
|
# 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output)
|
||||||
#
|
#
|
||||||
# Config via env — passed through unchanged:
|
# 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"; }
|
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"; }
|
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"; }
|
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'; }
|
clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; }
|
||||||
|
|
||||||
spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; }
|
spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; }
|
||||||
@@ -105,21 +121,20 @@ rm -f "$PROGRESS_LOG"
|
|||||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||||
write_header
|
write_header
|
||||||
|
|
||||||
cat <<'EOF'
|
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
|
||||||
═══════════════════════════════════════════════════════════════
|
# and skip printing these again, so the flow stays visually continuous.
|
||||||
NanoClaw scripted setup
|
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||||
═══════════════════════════════════════════════════════════════
|
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
|
||||||
|
|
||||||
Phase 1 · bootstrap
|
# ─── first step: install the basics (Node + pnpm + native modules) ─────
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ─── phase 1: bootstrap ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
||||||
BOOTSTRAP_LABEL="Bootstrapping Node, pnpm, native modules"
|
BOOTSTRAP_LABEL="Installing the basics"
|
||||||
BOOTSTRAP_START=$(date +%s)
|
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"
|
spinner_start "$BOOTSTRAP_LABEL"
|
||||||
|
|
||||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
# 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 ))
|
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
||||||
|
|
||||||
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
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"
|
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||||
else
|
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_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||||
write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}"
|
write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}"
|
||||||
|
|
||||||
@@ -162,23 +177,19 @@ else
|
|||||||
echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')"
|
echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')"
|
||||||
tail -40 "$BOOTSTRAP_RAW"
|
tail -40 "$BOOTSTRAP_RAW"
|
||||||
echo
|
echo
|
||||||
echo "Full raw log: $BOOTSTRAP_RAW"
|
echo "$(dim "Full raw log: $BOOTSTRAP_RAW")"
|
||||||
echo "Progression: $PROGRESS_LOG"
|
echo "$(dim "Progression: $PROGRESS_LOG")"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
# ─── hand off to setup:auto ────────────────────────────────────────────
|
||||||
cat <<'EOF'
|
|
||||||
Phase 2 · setup:auto
|
|
||||||
|
|
||||||
EOF
|
# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts to skip the wordmark (we
|
||||||
|
# already printed it) and to append to the progression log rather than
|
||||||
# ─── phase 2: clack driver ──────────────────────────────────────────────
|
# wipe it.
|
||||||
|
|
||||||
# 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.
|
|
||||||
export NANOCLAW_BOOTSTRAPPED=1
|
export NANOCLAW_BOOTSTRAPPED=1
|
||||||
|
|
||||||
# exec so signals (Ctrl-C) propagate directly to the child.
|
# --silent suppresses pnpm's `> nanoclaw@1.2.52 setup:auto / > tsx setup/auto.ts`
|
||||||
exec pnpm run setup:auto
|
# 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
|
||||||
|
|||||||
286
setup/auto.ts
286
setup/auto.ts
@@ -27,7 +27,7 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import { runTelegramChannel } from './channels/telegram.js';
|
import { runTelegramChannel } from './channels/telegram.js';
|
||||||
import * as setupLog from './logs.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';
|
import { brandBold, brandChip } from './lib/theme.js';
|
||||||
|
|
||||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||||
@@ -46,121 +46,116 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
if (!skip.has('environment')) {
|
if (!skip.has('environment')) {
|
||||||
const res = await runQuietStep('environment', {
|
const res = await runQuietStep('environment', {
|
||||||
running: 'Checking environment…',
|
running: 'Checking your system…',
|
||||||
done: 'Environment OK.',
|
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')) {
|
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', {
|
const res = await runQuietStep('container', {
|
||||||
running: 'Building the agent container image…',
|
running: 'Preparing the sandbox your assistant runs in…',
|
||||||
done: 'Container image ready.',
|
done: 'Sandbox ready.',
|
||||||
failed: 'Container build failed.',
|
failed: "Couldn't prepare the sandbox.",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = res.terminal?.fields.ERROR;
|
const err = res.terminal?.fields.ERROR;
|
||||||
if (err === 'runtime_not_available') {
|
if (err === 'runtime_not_available') {
|
||||||
fail(
|
fail(
|
||||||
'container',
|
'container',
|
||||||
'Docker is not available and could not be started automatically.',
|
"Docker isn't available.",
|
||||||
'Install Docker Desktop or start it manually, then retry.',
|
'Install Docker Desktop (or start it if already installed), then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (err === 'docker_group_not_active') {
|
if (err === 'docker_group_not_active') {
|
||||||
fail(
|
fail(
|
||||||
'container',
|
'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.',
|
'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fail(
|
fail(
|
||||||
'container',
|
'container',
|
||||||
'Container build/test failed.',
|
"Couldn't build the sandbox.",
|
||||||
'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.',
|
'If Docker has a stale cache, try: `docker builder prune -f`, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
maybeReexecUnderSg();
|
maybeReexecUnderSg();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('onecli')) {
|
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', {
|
const res = await runQuietStep('onecli', {
|
||||||
running: 'Installing OneCLI credential vault…',
|
running: "Setting up OneCLI, your agent's vault…",
|
||||||
done: 'OneCLI installed.',
|
done: 'OneCLI vault ready.',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = res.terminal?.fields.ERROR;
|
const err = res.terminal?.fields.ERROR;
|
||||||
if (err === 'onecli_not_on_path_after_install') {
|
if (err === 'onecli_not_on_path_after_install') {
|
||||||
fail(
|
fail(
|
||||||
'onecli',
|
'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.',
|
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fail(
|
fail(
|
||||||
'onecli',
|
'onecli',
|
||||||
`OneCLI install failed (${err ?? 'unknown'}).`,
|
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||||
'Check that curl + a writable ~/.local/bin are available, then retry.',
|
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('auth')) {
|
if (!skip.has('auth')) {
|
||||||
if (anthropicSecretExists()) {
|
await runAuthStep();
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('mounts')) {
|
if (!skip.has('mounts')) {
|
||||||
const res = await runQuietStep(
|
const res = await runQuietStep(
|
||||||
'mounts',
|
'mounts',
|
||||||
{
|
{
|
||||||
running: 'Writing mount allowlist…',
|
running: "Setting your assistant's access rules…",
|
||||||
done: 'Mount allowlist in place.',
|
done: 'Access rules set.',
|
||||||
skipped: 'Mount allowlist already configured.',
|
skipped: 'Access rules already set.',
|
||||||
},
|
},
|
||||||
['--empty'],
|
['--empty'],
|
||||||
);
|
);
|
||||||
if (!res.ok) fail('mounts', 'Mount allowlist step failed.');
|
if (!res.ok) {
|
||||||
|
fail('mounts', "Couldn't write access rules.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('service')) {
|
if (!skip.has('service')) {
|
||||||
const res = await runQuietStep('service', {
|
const res = await runQuietStep('service', {
|
||||||
running: 'Installing the background service…',
|
running: 'Starting NanoClaw in the background…',
|
||||||
done: 'Service installed and running.',
|
done: 'NanoClaw is running.',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
fail(
|
fail(
|
||||||
'service',
|
'service',
|
||||||
'Service install failed.',
|
"Couldn't start NanoClaw.",
|
||||||
'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.',
|
'See logs/nanoclaw.error.log for details.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
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(
|
p.log.message(
|
||||||
k.dim(
|
k.dim(
|
||||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||||
@@ -182,16 +177,16 @@ async function main(): Promise<void> {
|
|||||||
const res = await runQuietStep(
|
const res = await runQuietStep(
|
||||||
'cli-agent',
|
'cli-agent',
|
||||||
{
|
{
|
||||||
running: 'Wiring the terminal agent…',
|
running: 'Setting up your terminal chat…',
|
||||||
done: 'Terminal agent wired (try `pnpm run chat hi`).',
|
done: 'Terminal chat ready. Try `pnpm run chat hi`.',
|
||||||
},
|
},
|
||||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
fail(
|
fail(
|
||||||
'cli-agent',
|
'cli-agent',
|
||||||
'CLI agent wiring failed.',
|
"Couldn't set up the terminal chat.",
|
||||||
`Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`,
|
`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<void> {
|
|||||||
if (choice === 'telegram') {
|
if (choice === 'telegram') {
|
||||||
await runTelegramChannel(displayName!);
|
await runTelegramChannel(displayName!);
|
||||||
} else {
|
} else {
|
||||||
p.log.info('No messaging channel wired — you can add one later with `/add-<channel>`.');
|
p.log.info(
|
||||||
|
"No messaging app for now. You can add one later (like Telegram, Slack, or Discord).",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('verify')) {
|
if (!skip.has('verify')) {
|
||||||
const res = await runQuietStep('verify', {
|
const res = await runQuietStep('verify', {
|
||||||
running: 'Verifying the install…',
|
running: 'Making sure everything works together…',
|
||||||
done: 'Install verified.',
|
done: "Everything's connected.",
|
||||||
failed: 'Verification found issues.',
|
failed: 'A few things still need your attention.',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
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;
|
const agentPing = res.terminal?.fields.AGENT_PING;
|
||||||
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
||||||
notes.push(
|
notes.push(
|
||||||
`• CLI agent did not reply (status: ${agentPing}). ` +
|
"• Your assistant didn't reply to a test message. " +
|
||||||
'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.',
|
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSteps = [
|
const rows: [string, string][] = [
|
||||||
`${k.cyan('Chat from the CLI:')} pnpm run chat hi`,
|
['Chat in the terminal:', 'pnpm run chat hi'],
|
||||||
`${k.cyan('Tail host logs:')} tail -f logs/nanoclaw.log`,
|
["See what's happening:", 'tail -f logs/nanoclaw.log'],
|
||||||
`${k.cyan('Open Claude Code:')} claude`,
|
['Open Claude Code:', 'claude'],
|
||||||
].join('\n');
|
];
|
||||||
p.note(nextSteps, 'Next steps');
|
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);
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 ────────────────────────────────────
|
// ─── prompts owned by the sequencer ────────────────────────────────────
|
||||||
@@ -249,7 +362,7 @@ async function main(): Promise<void> {
|
|||||||
async function askDisplayName(fallback: string): Promise<string> {
|
async function askDisplayName(fallback: string): Promise<string> {
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.text({
|
await p.text({
|
||||||
message: 'What should your agents call you?',
|
message: 'What should your assistant call you?',
|
||||||
placeholder: fallback,
|
placeholder: fallback,
|
||||||
defaultValue: fallback,
|
defaultValue: fallback,
|
||||||
}),
|
}),
|
||||||
@@ -262,10 +375,10 @@ async function askDisplayName(fallback: string): Promise<string> {
|
|||||||
async function askChannelChoice(): Promise<'telegram' | 'skip'> {
|
async function askChannelChoice(): Promise<'telegram' | 'skip'> {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
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: [
|
options: [
|
||||||
{ value: 'telegram', label: 'Telegram', hint: 'recommended' },
|
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||||
{ value: 'skip', label: 'Skip — use the CLI only' },
|
{ 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 (!/permission denied/i.test(err)) return;
|
||||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) 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'], {
|
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
||||||
@@ -323,17 +436,28 @@ function maybeReexecUnderSg(): void {
|
|||||||
|
|
||||||
function printIntro(): void {
|
function printIntro(): void {
|
||||||
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||||||
|
const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1';
|
||||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||||
|
|
||||||
if (isReexec) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` ${wordmark}`);
|
console.log(` ${wordmark}`);
|
||||||
console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`);
|
console.log(` ${k.dim('Setting up your personal AI assistant')}`);
|
||||||
p.intro(`${brandChip(' setup:auto ')}`);
|
p.intro(k.dim("Let's get you set up."));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
'bash',
|
'bash',
|
||||||
['setup/add-telegram.sh'],
|
['setup/add-telegram.sh'],
|
||||||
{
|
{
|
||||||
running: `Installing Telegram adapter and wiring @${botUsername}…`,
|
running: `Connecting Telegram to @${botUsername}…`,
|
||||||
done: 'Telegram adapter ready.',
|
done: 'Telegram connected.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
env: { TELEGRAM_BOT_TOKEN: token },
|
env: { TELEGRAM_BOT_TOKEN: token },
|
||||||
@@ -54,8 +54,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
if (!install.ok) {
|
if (!install.ok) {
|
||||||
fail(
|
fail(
|
||||||
'telegram-install',
|
'telegram-install',
|
||||||
'Telegram install failed.',
|
"Couldn't connect Telegram.",
|
||||||
'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.',
|
'See logs/setup-steps/ for details, then retry setup.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
if (!pair.ok) {
|
if (!pair.ok) {
|
||||||
fail(
|
fail(
|
||||||
'pair-telegram',
|
'pair-telegram',
|
||||||
'Telegram pairing failed.',
|
"Couldn't pair with Telegram.",
|
||||||
'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.',
|
'Re-run setup to try again.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
if (!platformId || !pairedUserId) {
|
if (!platformId || !pairedUserId) {
|
||||||
fail(
|
fail(
|
||||||
'pair-telegram',
|
'pair-telegram',
|
||||||
'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.',
|
'Pairing completed but came back incomplete.',
|
||||||
'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.',
|
'Re-run setup to try again.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +92,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
'--agent-name', agentName,
|
'--agent-name', agentName,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
running: `Wiring ${agentName} to your Telegram chat…`,
|
running: `Connecting ${agentName} to your Telegram chat…`,
|
||||||
done: `${agentName} is wired — welcome DM incoming.`,
|
done: `${agentName} is ready. Check Telegram for a welcome message.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
|
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
|
||||||
@@ -102,8 +102,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
if (!init.ok) {
|
if (!init.ok) {
|
||||||
fail(
|
fail(
|
||||||
'init-first-agent',
|
'init-first-agent',
|
||||||
'Wiring the Telegram agent failed.',
|
`Couldn't finish connecting ${agentName}.`,
|
||||||
`Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName}" --agent-name "${agentName}"\`.`,
|
'You can retry later with `/manage-channels`.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,24 +111,26 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
async function collectTelegramToken(): Promise<string> {
|
async function collectTelegramToken(): Promise<string> {
|
||||||
p.note(
|
p.note(
|
||||||
[
|
[
|
||||||
'1. Open Telegram and message @BotFather',
|
"Your assistant talks to you through a Telegram bot you create.",
|
||||||
'2. Send: /newbot',
|
"Here's how:",
|
||||||
'3. Follow the prompts (name + username ending in "bot")',
|
|
||||||
'4. Copy the token it gives you (format: <digits>:<chars>)',
|
|
||||||
'',
|
'',
|
||||||
k.dim('Optional, but recommended for groups:'),
|
' 1. Open Telegram and message @BotFather',
|
||||||
k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'),
|
' 2. Send /newbot and follow the prompts',
|
||||||
|
' 3. Copy the token it gives you (it looks like <digits>:<chars>)',
|
||||||
|
'',
|
||||||
|
k.dim('Planning to add your assistant to group chats? In @BotFather:'),
|
||||||
|
k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Create a Telegram bot',
|
'Set up your Telegram bot',
|
||||||
);
|
);
|
||||||
|
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Paste your bot token',
|
message: 'Paste your bot token',
|
||||||
validate: (v) => {
|
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())) {
|
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
|
||||||
return 'Format looks wrong — expected <digits>:<chars>';
|
return "That doesn't look right. It should be <digits>:<chars>";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
@@ -145,7 +147,7 @@ async function collectTelegramToken(): Promise<string> {
|
|||||||
async function validateTelegramToken(token: string): Promise<string> {
|
async function validateTelegramToken(token: string): Promise<string> {
|
||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
s.start('Validating token with Telegram…');
|
s.start('Checking your bot token…');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
@@ -156,7 +158,7 @@ async function validateTelegramToken(token: string): Promise<string> {
|
|||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||||
if (data.ok && data.result?.username) {
|
if (data.ok && data.result?.username) {
|
||||||
const username = 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, {
|
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||||
BOT_USERNAME: username,
|
BOT_USERNAME: username,
|
||||||
BOT_ID: data.result.id ?? '',
|
BOT_ID: data.result.id ?? '',
|
||||||
@@ -164,26 +166,26 @@ async function validateTelegramToken(token: string): Promise<string> {
|
|||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
const reason = data.description ?? 'token rejected by Telegram';
|
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, {
|
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: reason,
|
ERROR: reason,
|
||||||
});
|
});
|
||||||
fail(
|
fail(
|
||||||
'telegram-validate',
|
'telegram-validate',
|
||||||
'Telegram rejected the token.',
|
"Telegram didn't accept that token.",
|
||||||
'Double-check the token (copy it again from @BotFather) and retry.',
|
'Copy the token again from @BotFather and try setup once more.',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
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);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
});
|
});
|
||||||
fail(
|
fail(
|
||||||
'telegram-validate',
|
'telegram-validate',
|
||||||
'Telegram API unreachable.',
|
"Couldn't reach Telegram.",
|
||||||
'Check your network connection and retry.',
|
'Check your internet connection and retry setup.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +196,7 @@ async function runPairTelegram(): Promise<
|
|||||||
const rawLog = setupLog.stepRawLog('pair-telegram');
|
const rawLog = setupLog.stepRawLog('pair-telegram');
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
s.start('Creating pairing code…');
|
s.start('Generating a secret code for your bot…');
|
||||||
let spinnerActive = true;
|
let spinnerActive = true;
|
||||||
|
|
||||||
const stopSpinner = (msg: string, code?: number) => {
|
const stopSpinner = (msg: string, code?: number) => {
|
||||||
@@ -211,15 +213,15 @@ async function runPairTelegram(): Promise<
|
|||||||
if (block.type === 'PAIR_TELEGRAM_CODE') {
|
if (block.type === 'PAIR_TELEGRAM_CODE') {
|
||||||
const reason = block.fields.REASON ?? 'initial';
|
const reason = block.fields.REASON ?? 'initial';
|
||||||
if (reason === 'initial') {
|
if (reason === 'initial') {
|
||||||
stopSpinner('Pairing code ready.');
|
stopSpinner('Your secret code is ready.');
|
||||||
} else {
|
} 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');
|
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||||
s.start('Waiting for the code from Telegram…');
|
s.start('Waiting for you to send the code from Telegram…');
|
||||||
spinnerActive = true;
|
spinnerActive = true;
|
||||||
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
|
} 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…');
|
s.start('Waiting for the correct code…');
|
||||||
spinnerActive = true;
|
spinnerActive = true;
|
||||||
} else if (block.type === 'PAIR_TELEGRAM') {
|
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||||
@@ -238,7 +240,7 @@ async function runPairTelegram(): Promise<
|
|||||||
// sure we don't leave the spinner running.
|
// sure we don't leave the spinner running.
|
||||||
if (spinnerActive) {
|
if (spinnerActive) {
|
||||||
stopSpinner(
|
stopSpinner(
|
||||||
result.ok ? 'Done.' : 'Pairing exited unexpectedly.',
|
result.ok ? 'Done.' : 'Pairing ended unexpectedly.',
|
||||||
result.ok ? 0 : 1,
|
result.ok ? 0 : 1,
|
||||||
);
|
);
|
||||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||||
@@ -254,7 +256,7 @@ function formatCodeCard(code: string): string {
|
|||||||
'',
|
'',
|
||||||
` ${brandBold(spaced)}`,
|
` ${brandBold(spaced)}`,
|
||||||
'',
|
'',
|
||||||
k.dim(' Send these digits from Telegram to your bot.'),
|
k.dim(' Send this code to your bot from Telegram.'),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +268,7 @@ async function resolveAgentName(): Promise<string> {
|
|||||||
}
|
}
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.text({
|
await p.text({
|
||||||
message: 'What should your messaging agent be called?',
|
message: 'What should your assistant be called?',
|
||||||
placeholder: DEFAULT_AGENT_NAME,
|
placeholder: DEFAULT_AGENT_NAME,
|
||||||
defaultValue: DEFAULT_AGENT_NAME,
|
defaultValue: DEFAULT_AGENT_NAME,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,128 +1,88 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in
|
# Register a Claude subscription OAuth token with OneCLI — the *only* auth
|
||||||
# /bin/bash, but Homebrew users usually have 5.x first on PATH. The readline
|
# path that needs a TTY break in the flow. Paste-based paths (existing
|
||||||
# preload is optional — on 3.x we fall back to a plain confirmation prompt.
|
# OAuth token / API key) are handled in-process by setup/auto.ts using
|
||||||
|
# clack prompts, then onecli secrets create is invoked directly from TS.
|
||||||
# Register an Anthropic credential with OneCLI. Three paths:
|
#
|
||||||
# 1) Claude subscription — run `claude setup-token` (browser sign-in)
|
# Flow:
|
||||||
# and capture the resulting OAuth token.
|
# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser
|
||||||
# 2) Paste an existing sk-ant-oat… OAuth token you already have.
|
# OAuth dance works and its token is captured into a tempfile.
|
||||||
# 3) Paste an Anthropic API key (sk-ant-api…).
|
# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture.
|
||||||
|
# 3. Register it with OneCLI.
|
||||||
#
|
#
|
||||||
# Env overrides:
|
# Env overrides:
|
||||||
# SECRET_NAME OneCLI secret name (default: Anthropic)
|
# SECRET_NAME OneCLI secret name (default: Anthropic)
|
||||||
# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com)
|
# 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}"
|
SECRET_NAME="${SECRET_NAME:-Anthropic}"
|
||||||
HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}"
|
HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}"
|
||||||
|
|
||||||
command -v onecli >/dev/null \
|
command -v onecli >/dev/null \
|
||||||
|| { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; }
|
|| { 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() {
|
cat <<'EOF'
|
||||||
command -v claude >/dev/null \
|
A browser window will open for you to sign in with your Claude account.
|
||||||
|| { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; }
|
When you finish, we'll save the token to your OneCLI vault automatically.
|
||||||
command -v script >/dev/null \
|
|
||||||
|| { echo "script(1) is required for PTY capture." >&2; exit 1; }
|
|
||||||
|
|
||||||
local tmpfile
|
Press Enter to continue, or edit the command first.
|
||||||
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.
|
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
local cmd="claude setup-token"
|
cmd="claude setup-token"
|
||||||
if [[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]; then
|
if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
|
||||||
# bash 4+: pre-fill the readline buffer so Enter literally submits.
|
# bash 4+: pre-fill the readline buffer so Enter literally submits.
|
||||||
read -r -e -i "$cmd" -p "$ " cmd </dev/tty
|
read -r -e -i "$cmd" -p "$ " cmd </dev/tty
|
||||||
else
|
else
|
||||||
# bash 3.x (macOS default /bin/bash): no readline preload. Fall back.
|
# bash 3.x (macOS default /bin/bash): no readline preload. Fall back.
|
||||||
echo "$ $cmd"
|
echo "$ $cmd"
|
||||||
read -r -p "Press Enter to run, Ctrl-C to abort. " _ </dev/tty
|
read -r -p "Press Enter to run, Ctrl-C to abort. " _ </dev/tty
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# `script` arg order differs between BSD (macOS) and util-linux.
|
# `script` arg order differs between BSD (macOS) and util-linux.
|
||||||
if script --version 2>/dev/null | grep -q util-linux; then
|
if script --version 2>/dev/null | grep -q util-linux; then
|
||||||
script -q -c "$cmd" "$tmpfile"
|
script -q -c "$cmd" "$tmpfile"
|
||||||
else
|
else
|
||||||
# BSD script: command is argv after the file, so let it word-split.
|
# BSD script: command is argv after the file, so let it word-split.
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
script -q "$tmpfile" $cmd
|
script -q "$tmpfile" $cmd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match
|
# 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.
|
# 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" \
|
token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \
|
||||||
| tr -d '\n\r' \
|
| tr -d '\n\r' \
|
||||||
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
|
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
|
||||||
| tail -1 || true)
|
| tail -1 || true)
|
||||||
|
|
||||||
if [[ -z "$TOKEN" ]]; then
|
if [ -z "$token" ]; then
|
||||||
local keep
|
keep=$(mktemp -t claude-setup-token-log.XXXXXX)
|
||||||
keep=$(mktemp -t claude-setup-token-log.XXXXXX)
|
cp "$tmpfile" "$keep"
|
||||||
cp "$tmpfile" "$keep"
|
echo >&2
|
||||||
echo >&2
|
echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2
|
||||||
echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2
|
exit 1
|
||||||
exit 1
|
fi
|
||||||
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 </dev/tty
|
|
||||||
echo
|
|
||||||
|
|
||||||
if [[ -z "$value" ]]; then
|
|
||||||
echo "No input. Aborting." >&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 <<EOF
|
|
||||||
How would you like to authenticate?
|
|
||||||
|
|
||||||
1) Use Claude subscription — runs \`claude setup-token\` and saves the
|
|
||||||
resulting token in the Agent Vault.
|
|
||||||
2) I have my own OAuth token — paste an existing sk-ant-oat… token.
|
|
||||||
3) I have my own API key — paste an Anthropic API key (sk-ant-api…).
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
read -r -p "Choose [1/2/3]: " CHOICE </dev/tty
|
|
||||||
|
|
||||||
case "$CHOICE" in
|
|
||||||
1) capture_via_claude_setup_token ;;
|
|
||||||
2) prompt_for_pasted oat ;;
|
|
||||||
3) prompt_for_pasted api ;;
|
|
||||||
*) echo "Invalid choice." >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}"
|
echo "Got token: ${token:0:16}…${token: -4}"
|
||||||
echo "Registering with OneCLI as '${SECRET_NAME}' (host pattern: ${HOST_PATTERN})…"
|
echo "Saving it to your OneCLI vault as '${SECRET_NAME}' (host: ${HOST_PATTERN})…"
|
||||||
|
|
||||||
onecli secrets create \
|
onecli secrets create \
|
||||||
--name "$SECRET_NAME" \
|
--name "$SECRET_NAME" \
|
||||||
--type anthropic \
|
--type anthropic \
|
||||||
--value "$TOKEN" \
|
--value "$token" \
|
||||||
--host-pattern "$HOST_PATTERN"
|
--host-pattern "$HOST_PATTERN"
|
||||||
|
|
||||||
echo "Done."
|
echo "Done."
|
||||||
|
|||||||
Reference in New Issue
Block a user