mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
1db98ee614
Remove the grouped detectExistingEnv() block that asked "reuse all or start fresh" at the top of setup. Each channel step now reads credentials directly from .env on disk via readEnvKey() and offers to reuse them individually at the point of use. - Add readEnvKey() helper in setup/environment.ts - Remove ENV_KEY_GROUPS, ExistingEnvGroup, detectExistingEnv from auto.ts - Move detectRegisteredGroups skip to right before cli-agent step - Switch all channel files (telegram, discord, slack, teams, imessage) from process.env to readEnvKey() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1178 lines
40 KiB
TypeScript
1178 lines
40 KiB
TypeScript
/**
|
||
* Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`.
|
||
*
|
||
* Responsibility: orchestrate the sequence of steps end-to-end and route
|
||
* between them. The runner, spawning, status parsing, spinner, abort, and
|
||
* prompt primitives live in `setup/lib/runner.ts`; theming in
|
||
* `setup/lib/theme.ts`; Telegram's full flow in `setup/channels/telegram.ts`.
|
||
*
|
||
* Config via env:
|
||
* NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the
|
||
* prompt. Defaults to $USER.
|
||
* NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the
|
||
* channel flow). The CLI scratch agent is always
|
||
* "Terminal Agent".
|
||
* NANOCLAW_SKIP comma-separated step names to skip
|
||
* (environment|container|onecli|auth|mounts|
|
||
* service|cli-agent|timezone|channel|verify|
|
||
* first-chat)
|
||
*
|
||
* Timezone is auto-detected after the CLI agent step. UTC resolves are
|
||
* confirmed with the user, and free-text replies fall through to a
|
||
* headless `claude -p` call for IANA-zone resolution.
|
||
*/
|
||
import { spawn, spawnSync } from 'child_process';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
|
||
import * as p from '@clack/prompts';
|
||
import k from 'kleur';
|
||
|
||
import { runDiscordChannel } from './channels/discord.js';
|
||
import { runIMessageChannel } from './channels/imessage.js';
|
||
import { runSignalChannel } from './channels/signal.js';
|
||
import { runSlackChannel } from './channels/slack.js';
|
||
import { runTeamsChannel } from './channels/teams.js';
|
||
import { runTelegramChannel } from './channels/telegram.js';
|
||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||
import { brightSelect } from './lib/bright-select.js';
|
||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||
import {
|
||
applyToEnv,
|
||
parseFlags,
|
||
printHelp,
|
||
readFromEnv,
|
||
} 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 { emit as phEmit } from './lib/diagnostics.js';
|
||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js';
|
||
import { isValidTimezone } from '../src/timezone.js';
|
||
|
||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||
const RUN_START = Date.now();
|
||
|
||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
||
|
||
async function main(): Promise<void> {
|
||
// Parse CLI flags first — `--help` short-circuits before we render anything,
|
||
// and flag values get folded into process.env so existing step code reading
|
||
// NANOCLAW_* sees them unchanged.
|
||
const flagResult = parseFlags(process.argv.slice(2));
|
||
if (flagResult.help) {
|
||
printHelp();
|
||
process.exit(0);
|
||
}
|
||
if (flagResult.errors.length > 0) {
|
||
for (const err of flagResult.errors) console.error(`error: ${err}`);
|
||
console.error('');
|
||
console.error('Run with --help for the full list of supported flags.');
|
||
process.exit(1);
|
||
}
|
||
let configValues = { ...readFromEnv(), ...flagResult.values };
|
||
applyToEnv(configValues);
|
||
|
||
printIntro();
|
||
initProgressionLog();
|
||
phEmit('auto_started');
|
||
|
||
// 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);
|
||
if (startChoice === 'advanced') {
|
||
configValues = await runAdvancedScreen(configValues);
|
||
applyToEnv(configValues);
|
||
}
|
||
|
||
const skip = new Set(
|
||
(process.env.NANOCLAW_SKIP ?? '')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean),
|
||
);
|
||
|
||
if (!skip.has('environment')) {
|
||
const res = await runQuietStep('environment', {
|
||
running: 'Checking your system…',
|
||
done: 'Your system looks good.',
|
||
});
|
||
if (!res.ok) {
|
||
await fail(
|
||
'environment',
|
||
"Your system doesn't look quite right.",
|
||
'See logs/setup-steps/ for details, then retry.',
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!skip.has('container')) {
|
||
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
||
p.log.message(
|
||
brandBody(
|
||
dimWrap(
|
||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||
4,
|
||
),
|
||
),
|
||
);
|
||
const res = await runWindowedStep('container', {
|
||
running: "Preparing your assistant's sandbox…",
|
||
done: 'Sandbox ready.',
|
||
failed: "Couldn't prepare the sandbox.",
|
||
});
|
||
if (!res.ok) {
|
||
const err = res.terminal?.fields.ERROR;
|
||
if (err === 'runtime_not_available') {
|
||
await fail(
|
||
'container',
|
||
"Docker isn't available.",
|
||
'Install Docker Desktop (or start it if already installed), then retry.',
|
||
);
|
||
}
|
||
if (err === 'docker_group_not_active') {
|
||
await fail(
|
||
'container',
|
||
"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.',
|
||
);
|
||
}
|
||
await fail(
|
||
'container',
|
||
"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(
|
||
brandBody(
|
||
dimWrap(
|
||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||
4,
|
||
),
|
||
),
|
||
);
|
||
|
||
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
|
||
|
||
if (remoteHost) {
|
||
// Advanced-settings override: user has already named a remote vault,
|
||
// so skip the local-vs-fresh prompt entirely. Health-check it here
|
||
// rather than letting the step fail silently — a typo in the URL is a
|
||
// common mistake and the answer is human-fixable.
|
||
const s = p.spinner();
|
||
s.start(`Checking remote OneCLI at ${remoteHost}…`);
|
||
const healthy = await pollHealth(remoteHost, 5000);
|
||
if (!healthy) {
|
||
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
|
||
await fail(
|
||
'onecli',
|
||
`Couldn't reach OneCLI at ${remoteHost}.`,
|
||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||
);
|
||
}
|
||
s.stop('Remote OneCLI is reachable.');
|
||
|
||
const res = await runQuietStep(
|
||
'onecli',
|
||
{
|
||
running: `Connecting to remote OneCLI at ${remoteHost}…`,
|
||
done: 'OneCLI vault ready.',
|
||
},
|
||
['--remote-url', remoteHost],
|
||
);
|
||
if (!res.ok) {
|
||
const err = res.terminal?.fields.ERROR;
|
||
await fail(
|
||
'onecli',
|
||
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
|
||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||
);
|
||
}
|
||
} else {
|
||
// Respect an existing OneCLI install. Re-running the installer would
|
||
// rebind the listener and knock any other app using that gateway
|
||
// offline — confirm with the user before doing that.
|
||
const existing = detectExistingOnecli();
|
||
let reuse = false;
|
||
if (existing) {
|
||
const choice = ensureAnswer(
|
||
await brightSelect({
|
||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||
options: [
|
||
{
|
||
value: 'reuse',
|
||
label: 'Use the existing instance',
|
||
hint: 'recommended — keeps other apps bound to this vault working',
|
||
},
|
||
{
|
||
value: 'fresh',
|
||
label: 'Install a fresh instance for NanoClaw',
|
||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||
},
|
||
],
|
||
}),
|
||
) as 'reuse' | 'fresh';
|
||
setupLog.userInput('onecli_choice', choice);
|
||
reuse = choice === 'reuse';
|
||
}
|
||
|
||
const res = await runQuietStep(
|
||
'onecli',
|
||
{
|
||
running: reuse
|
||
? 'Hooking up to your existing OneCLI…'
|
||
: "Setting up OneCLI, your agent's vault…",
|
||
done: 'OneCLI vault ready.',
|
||
},
|
||
reuse ? ['--reuse'] : [],
|
||
);
|
||
if (!res.ok) {
|
||
const err = res.terminal?.fields.ERROR;
|
||
if (err === 'onecli_not_on_path_after_install') {
|
||
await fail(
|
||
'onecli',
|
||
'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.',
|
||
);
|
||
}
|
||
await fail(
|
||
'onecli',
|
||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!skip.has('auth')) {
|
||
await runAuthStep();
|
||
}
|
||
|
||
if (!skip.has('mounts')) {
|
||
const res = await runQuietStep(
|
||
'mounts',
|
||
{
|
||
running: "Setting your assistant's access rules…",
|
||
done: 'Access rules set.',
|
||
skipped: 'Access rules already set.',
|
||
},
|
||
['--empty'],
|
||
);
|
||
if (!res.ok) {
|
||
await fail('mounts', "Couldn't write access rules.");
|
||
}
|
||
}
|
||
|
||
if (!skip.has('service')) {
|
||
const res = await runQuietStep('service', {
|
||
running: 'Starting NanoClaw in the background…',
|
||
done: 'NanoClaw is running.',
|
||
});
|
||
if (!res.ok) {
|
||
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(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
|
||
p.log.message(
|
||
brandBody(
|
||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
let displayName: string | undefined;
|
||
async function resolveDisplayName(): Promise<string> {
|
||
if (displayName) return displayName;
|
||
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
|
||
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],
|
||
);
|
||
if (!res.ok) {
|
||
await fail(
|
||
'cli-agent',
|
||
"Couldn't bring your assistant online.",
|
||
`You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`,
|
||
);
|
||
}
|
||
if (!skip.has('first-chat')) {
|
||
p.log.message(
|
||
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 30–60 seconds while the sandbox warms up.",
|
||
4,
|
||
),
|
||
),
|
||
);
|
||
const ping = await confirmAssistantResponds();
|
||
if (ping === 'ok') {
|
||
phEmit('first_chat_ready');
|
||
const next = ensureAnswer(
|
||
await brightSelect<'continue' | 'chat'>({
|
||
message: 'What next?',
|
||
options: [
|
||
{
|
||
value: 'continue',
|
||
label: 'Continue with setup',
|
||
hint: 'recommended',
|
||
},
|
||
{
|
||
value: 'chat',
|
||
label: 'Pause here and chat with your agent from the terminal',
|
||
},
|
||
],
|
||
}),
|
||
) as 'continue' | 'chat';
|
||
setupLog.userInput('first_chat_choice', next);
|
||
if (next === 'chat') await runFirstChat();
|
||
} else {
|
||
phEmit('first_chat_failed', { reason: ping });
|
||
renderPingFailureNote(ping);
|
||
await offerClaudeAssist({
|
||
stepName: 'cli-agent',
|
||
msg:
|
||
ping === 'socket_error'
|
||
? "NanoClaw service isn't listening on its CLI socket."
|
||
: 'No reply from the assistant within 30 seconds.',
|
||
hint:
|
||
ping === 'socket_error'
|
||
? 'Socket at data/cli.sock did not accept a connection.'
|
||
: 'Agent container may be failing to start or authenticate.',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!skip.has('timezone')) {
|
||
await runTimezoneStep();
|
||
}
|
||
|
||
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') {
|
||
await runDiscordChannel(displayName!);
|
||
} else if (channelChoice === 'whatsapp') {
|
||
await runWhatsAppChannel(displayName!);
|
||
} else if (channelChoice === 'signal') {
|
||
await runSignalChannel(displayName!);
|
||
} else if (channelChoice === 'teams') {
|
||
await runTeamsChannel(displayName!);
|
||
} else if (channelChoice === 'slack') {
|
||
await runSlackChannel(displayName!);
|
||
} else if (channelChoice === 'imessage') {
|
||
await runIMessageChannel(displayName!);
|
||
} else {
|
||
p.log.info(
|
||
brandBody(
|
||
wrapForGutter(
|
||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||
4,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!skip.has('verify')) {
|
||
const res = await runQuietStep('verify', {
|
||
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("• Your Claude account isn't connected. Re-run setup and try again.");
|
||
}
|
||
const service = res.terminal?.fields.SERVICE;
|
||
if (service === 'running_other_checkout') {
|
||
const label = getLaunchdLabel();
|
||
notes.push(
|
||
wrapForGutter(
|
||
[
|
||
'• Your NanoClaw service is running from a different folder on this machine.',
|
||
' Point it at this checkout with:',
|
||
` launchctl bootout gui/$(id -u)/${label}`,
|
||
` launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${label}.plist`,
|
||
].join('\n'),
|
||
6,
|
||
),
|
||
);
|
||
} else {
|
||
const agentPing = res.terminal?.fields.AGENT_PING;
|
||
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
||
notes.push(
|
||
"• 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(
|
||
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
|
||
);
|
||
}
|
||
if (notes.length > 0) {
|
||
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.
|
||
const summary = notes
|
||
.map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim())
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
phEmit('setup_incomplete', {
|
||
unresolved_count: notes.length,
|
||
service_running: res.terminal?.fields.SERVICE === 'running',
|
||
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
||
agent_responds: res.terminal?.fields.AGENT_PING === 'ok',
|
||
});
|
||
await offerClaudeAssist({
|
||
stepName: 'verify',
|
||
msg: summary || 'Verification completed with unresolved issues.',
|
||
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
||
rawLogPath: res.rawLog,
|
||
});
|
||
p.outro(k.yellow('Almost there. A few things still need your attention.'));
|
||
return;
|
||
}
|
||
}
|
||
|
||
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');
|
||
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.
|
||
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,
|
||
),
|
||
'Heads up',
|
||
);
|
||
|
||
setupLog.complete(Date.now() - RUN_START);
|
||
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
|
||
|
||
const dmTarget = channelDmLabel(channelChoice);
|
||
if (dmTarget) {
|
||
// Bright framed banner (not dim) — the whole point of the feedback was
|
||
// 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.
|
||
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`."));
|
||
}
|
||
}
|
||
|
||
function channelDmLabel(choice: ChannelChoice): string | null {
|
||
switch (choice) {
|
||
case 'telegram':
|
||
return 'Telegram';
|
||
case 'discord':
|
||
return 'Discord DMs';
|
||
case 'whatsapp':
|
||
return 'WhatsApp';
|
||
case 'signal':
|
||
return 'Signal';
|
||
case 'teams':
|
||
return 'Teams';
|
||
case 'imessage':
|
||
return 'iMessage';
|
||
case 'slack':
|
||
return 'Slack DMs';
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─── first-chat step ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Round-trip ping against the CLI socket before we ask the user to chat.
|
||
* Renders its own spinner with elapsed time because a cold-start container
|
||
* boot can take 30–60s — the elapsed counter is the difference between
|
||
* "patient" and "is this hung?". Returns the raw result so the caller can
|
||
* branch between the chat loop (ok) and a diagnostic note (anything else).
|
||
*/
|
||
async function confirmAssistantResponds(): Promise<PingResult> {
|
||
const s = p.spinner();
|
||
const start = Date.now();
|
||
const label = 'Waking your assistant…';
|
||
s.start(fitToWidth(label, ' (999s)'));
|
||
const tick = setInterval(() => {
|
||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||
const suffix = ` (${elapsed}s)`;
|
||
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)`;
|
||
if (result === 'ok') {
|
||
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
||
} else {
|
||
const msg =
|
||
result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
|
||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function renderPingFailureNote(result: PingResult): void {
|
||
const body =
|
||
result === 'socket_error'
|
||
? [
|
||
wrapForGutter(
|
||
"The NanoClaw service isn't listening on its local socket. Try restarting it, then chat with `pnpm run chat hi`:",
|
||
6,
|
||
),
|
||
'',
|
||
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
|
||
` Linux: systemctl --user restart ${getSystemdUnit()}`,
|
||
].join('\n')
|
||
: wrapForGutter(
|
||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||
6,
|
||
);
|
||
note(body, 'Skipping the first chat');
|
||
}
|
||
|
||
/**
|
||
* Chat loop. Each message is piped through `pnpm run chat`, which uses
|
||
* the same Unix-socket path the ping just exercised, so output streams
|
||
* back inline as the agent replies. An empty input ends the loop.
|
||
*
|
||
* The intro note teaches the sandbox mental model — users reported being
|
||
* confused about what the terminal chat *is* (vs the phone channel they'd
|
||
* set up next) and what happens to the agent when they walk away. We
|
||
* explain once, then offer "message or Enter to continue" so the chat is
|
||
* clearly optional.
|
||
*/
|
||
async function runFirstChat(): Promise<void> {
|
||
note(
|
||
wrapForGutter(
|
||
[
|
||
'Your assistant runs in a sandbox on this machine.',
|
||
'It wakes up when you send a message and goes back to sleep when',
|
||
"you're not talking — so it isn't burning resources in the background.",
|
||
'Its memory and environment persist between conversations.',
|
||
].join(' '),
|
||
6,
|
||
),
|
||
'How this works',
|
||
);
|
||
let first = true;
|
||
while (true) {
|
||
const answer = ensureAnswer(
|
||
await p.text({
|
||
message: first
|
||
? 'Try a quick hello — or press Enter to continue setup'
|
||
: 'Another message? Press Enter to continue setup',
|
||
placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
|
||
}),
|
||
);
|
||
first = false;
|
||
const text = ((answer as string | undefined) ?? '').trim();
|
||
if (!text) return;
|
||
await sendChatMessage(text);
|
||
}
|
||
}
|
||
|
||
function sendChatMessage(message: string): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
// `pnpm --silent` suppresses the `> nanoclaw@… chat` preamble so the
|
||
// agent's reply reads as a clean block under the prompt. Splitting on
|
||
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
|
||
// with spaces on the far side.
|
||
const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
|
||
stdio: ['ignore', 'inherit', 'inherit'],
|
||
});
|
||
child.on('close', () => resolve());
|
||
child.on('error', () => resolve());
|
||
});
|
||
}
|
||
|
||
// ─── auth step (select → branch) ────────────────────────────────────────
|
||
|
||
async function runAuthStep(): Promise<void> {
|
||
if (anthropicSecretExists()) {
|
||
p.log.success(brandBody('Your Claude account is already connected.'));
|
||
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
|
||
return;
|
||
}
|
||
|
||
// Custom Anthropic-compatible endpoint flow. Both URL and token must be set;
|
||
// OneCLI stores the token as a generic Bearer secret keyed to the URL host,
|
||
// so the container only ever sees ANTHROPIC_BASE_URL + a placeholder.
|
||
const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim();
|
||
const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim();
|
||
if (customBaseUrl && customAuthToken) {
|
||
await runCustomEndpointAuth(customBaseUrl, customAuthToken);
|
||
return;
|
||
}
|
||
|
||
const method = ensureAnswer(
|
||
await brightSelect({
|
||
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);
|
||
phEmit('auth_method_chosen', { method });
|
||
|
||
if (method === 'subscription') {
|
||
await runSubscriptionAuth();
|
||
} else {
|
||
await runPasteAuth(method);
|
||
}
|
||
}
|
||
|
||
async function runSubscriptionAuth(): Promise<void> {
|
||
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();
|
||
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',
|
||
});
|
||
await 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(brandBody('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}`,
|
||
clearOnError: true,
|
||
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) {
|
||
await fail(
|
||
'auth',
|
||
`Couldn't save your ${label} to the vault.`,
|
||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set up Anthropic auth for a custom endpoint. The token is stored as a
|
||
* OneCLI generic secret with header injection so the proxy rewrites the
|
||
* Authorization header on the wire — the container only ever sees
|
||
* ANTHROPIC_BASE_URL + a placeholder bearer.
|
||
*/
|
||
async function runCustomEndpointAuth(
|
||
baseUrl: string,
|
||
token: string,
|
||
): Promise<void> {
|
||
let host: string;
|
||
try {
|
||
host = new URL(baseUrl).hostname;
|
||
} catch {
|
||
await fail(
|
||
'auth',
|
||
`Invalid Anthropic base URL: ${baseUrl}`,
|
||
'Check --anthropic-base-url and retry.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
const res = await runQuietChild(
|
||
'auth',
|
||
'onecli',
|
||
[
|
||
'secrets',
|
||
'create',
|
||
'--name',
|
||
'Anthropic',
|
||
'--type',
|
||
'generic',
|
||
'--value',
|
||
token,
|
||
'--host-pattern',
|
||
host,
|
||
'--header-name',
|
||
'Authorization',
|
||
'--value-format',
|
||
'Bearer {value}',
|
||
],
|
||
{
|
||
running: `Saving your Anthropic auth token to your OneCLI vault…`,
|
||
done: 'Claude account connected.',
|
||
},
|
||
{ extraFields: { METHOD: 'custom-endpoint', HOST: host } },
|
||
);
|
||
if (!res.ok) {
|
||
await fail(
|
||
'auth',
|
||
`Couldn't save your Anthropic auth token to the vault.`,
|
||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||
);
|
||
}
|
||
|
||
// ANTHROPIC_BASE_URL has to be in .env so the runtime provider config
|
||
// reads it when building container env. The token is *not* written —
|
||
// OneCLI holds it.
|
||
writeEnvLine('ANTHROPIC_BASE_URL', baseUrl);
|
||
|
||
// Register the claude provider so the runtime passes ANTHROPIC_BASE_URL
|
||
// and the placeholder bearer into the container. Only appended when the
|
||
// user has configured a custom endpoint; standard installs don't load
|
||
// the file at all.
|
||
appendProviderImport('./claude.js');
|
||
}
|
||
|
||
function writeEnvLine(key: string, value: string): void {
|
||
const envFile = path.join(process.cwd(), '.env');
|
||
const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||
const re = new RegExp(`^${key}=.*$`, 'm');
|
||
const next = re.test(content)
|
||
? content.replace(re, `${key}=${value}`)
|
||
: content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`;
|
||
fs.writeFileSync(envFile, next);
|
||
}
|
||
|
||
function appendProviderImport(modulePath: string): void {
|
||
const file = path.join(process.cwd(), 'src', 'providers', 'index.ts');
|
||
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||
const line = `import '${modulePath}';`;
|
||
if (content.includes(line)) return;
|
||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||
fs.writeFileSync(file, content + sep + line + '\n');
|
||
}
|
||
|
||
// ─── timezone step ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Auto-detect TZ, confirm with the user when it comes back as UTC (a
|
||
* common sign we're on a VPS that wasn't localised), and persist through
|
||
* the usual `--step timezone -- --tz <zone>` path. Free-text answers get
|
||
* a headless `claude -p` pass to resolve them to a real IANA zone.
|
||
*/
|
||
async function runTimezoneStep(): Promise<void> {
|
||
const res = await runQuietStep('timezone', {
|
||
running: 'Checking your timezone…',
|
||
done: 'Timezone set.',
|
||
});
|
||
if (!res.ok && res.terminal?.fields.NEEDS_USER_INPUT !== 'true') {
|
||
await fail('timezone', "Couldn't determine your timezone.");
|
||
}
|
||
|
||
const fields = res.terminal?.fields ?? {};
|
||
const resolvedTz = fields.RESOLVED_TZ;
|
||
const needsInput = fields.NEEDS_USER_INPUT === 'true';
|
||
const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
|
||
|
||
// Three branches:
|
||
// - no TZ detected: ask where they are (or leave as UTC)
|
||
// - detected UTC: confirm (likely VPS, but worth checking)
|
||
// - detected specific zone: confirm explicitly rather than silently
|
||
// persisting — users shouldn't be surprised the agent "already knew"
|
||
// their timezone from system settings they didn't think about.
|
||
if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') {
|
||
const confirmed = ensureAnswer(
|
||
await p.confirm({
|
||
message: `I detected ${resolvedTz} from your computer settings. Is that right?`,
|
||
initialValue: true,
|
||
}),
|
||
);
|
||
setupLog.userInput('timezone_confirm_detected', String(confirmed));
|
||
if (confirmed) return;
|
||
}
|
||
|
||
const message = needsInput
|
||
? "Your system didn't expose a timezone. Which one are you in?"
|
||
: !isUtc
|
||
? 'Where are you, then?'
|
||
: 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
|
||
|
||
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
|
||
// straight to the free-text prompt — the user already said "not that".
|
||
let choice: 'keep' | 'answer' = 'answer';
|
||
if (needsInput || isUtc) {
|
||
choice = ensureAnswer(
|
||
await brightSelect({
|
||
message,
|
||
options: needsInput
|
||
? [
|
||
{ value: 'answer', label: "I'll tell you where I am" },
|
||
{ value: 'keep', label: 'Leave it as UTC' },
|
||
]
|
||
: [
|
||
{ value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' },
|
||
{ value: 'answer', label: "I'm somewhere else" },
|
||
],
|
||
}),
|
||
) as 'keep' | 'answer';
|
||
setupLog.userInput('timezone_choice', choice);
|
||
}
|
||
|
||
if (choice === 'keep') return;
|
||
|
||
const answer = ensureAnswer(
|
||
await p.text({
|
||
message: 'Where are you? (city, region, or IANA zone)',
|
||
placeholder: 'e.g. New York, London, Asia/Tokyo',
|
||
validate: (v) => (v && v.trim() ? undefined : 'Required'),
|
||
}),
|
||
);
|
||
const raw = (answer as string).trim();
|
||
setupLog.userInput('timezone_input', raw);
|
||
|
||
let tz: string | null = isValidTimezone(raw) ? raw : null;
|
||
if (!tz) {
|
||
if (claudeCliAvailable()) {
|
||
tz = await resolveTimezoneViaClaude(raw);
|
||
} else {
|
||
p.log.warn(
|
||
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!tz) {
|
||
// One retry with a direct-IANA ask; if that fails too, leave the
|
||
// previously-detected value in .env and move on rather than looping.
|
||
const retryAnswer = ensureAnswer(
|
||
await p.text({
|
||
message: 'Enter an IANA timezone string',
|
||
placeholder: 'e.g. America/New_York',
|
||
validate: (v) => {
|
||
const s = (v ?? '').trim();
|
||
if (!s) return 'Required';
|
||
if (!isValidTimezone(s)) return 'Not a valid IANA zone';
|
||
return undefined;
|
||
},
|
||
}),
|
||
);
|
||
tz = (retryAnswer as string).trim();
|
||
setupLog.userInput('timezone_retry', tz);
|
||
}
|
||
|
||
const persist = await runQuietStep(
|
||
'timezone',
|
||
{
|
||
running: `Saving timezone ${tz}…`,
|
||
done: `Timezone set to ${tz}.`,
|
||
},
|
||
['--tz', tz],
|
||
);
|
||
if (!persist.ok) {
|
||
await fail('timezone', `Couldn't save timezone ${tz}.`);
|
||
}
|
||
}
|
||
|
||
// ─── prompts owned by the sequencer ────────────────────────────────────
|
||
|
||
async function askDisplayName(fallback: string): Promise<string> {
|
||
const answer = ensureAnswer(
|
||
await p.text({
|
||
message: `What should your assistant call ${accentGreen('you')}?`,
|
||
placeholder: fallback,
|
||
defaultValue: fallback,
|
||
}),
|
||
);
|
||
const value = (answer as string).trim() || fallback;
|
||
setupLog.userInput('display_name', value);
|
||
return value;
|
||
}
|
||
|
||
async function askChannelChoice(): Promise<ChannelChoice> {
|
||
const isMac = process.platform === 'darwin';
|
||
const choice = ensureAnswer(
|
||
await brightSelect<ChannelChoice>({
|
||
message: 'Want to chat with your assistant from your phone?',
|
||
options: [
|
||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||
{
|
||
value: 'signal',
|
||
label: 'Yes, connect Signal',
|
||
hint: 'needs signal-cli installed',
|
||
},
|
||
{
|
||
value: 'imessage',
|
||
label: 'Yes, connect iMessage (experimental)',
|
||
hint: isMac ? 'local macOS mode' : 'remote Photon only',
|
||
},
|
||
{
|
||
value: 'slack',
|
||
label: 'Yes, connect Slack (experimental)',
|
||
hint: 'needs public URL',
|
||
},
|
||
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
|
||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||
],
|
||
}),
|
||
);
|
||
setupLog.userInput('channel_choice', String(choice));
|
||
phEmit('channel_chosen', { channel: String(choice) });
|
||
return choice;
|
||
}
|
||
|
||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||
|
||
function anthropicSecretExists(): boolean {
|
||
try {
|
||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
if (res.status !== 0) return false;
|
||
return /anthropic/i.test(res.stdout ?? '');
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Probe the host for a working OneCLI install so we can offer to reuse it
|
||
* instead of re-running the installer (which rebinds the listener and breaks
|
||
* any other app already using that gateway).
|
||
*/
|
||
function detectExistingOnecli(): { version: string; apiHost: string } | null {
|
||
try {
|
||
const ver = spawnSync('onecli', ['version'], {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'ignore'],
|
||
});
|
||
if (ver.status !== 0) return null;
|
||
const version = (ver.stdout ?? '').trim();
|
||
if (!version) return null;
|
||
|
||
const host = spawnSync('onecli', ['config', 'get', 'api-host'], {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'ignore'],
|
||
});
|
||
if (host.status !== 0) return null;
|
||
const raw = (host.stdout ?? '').trim();
|
||
if (!raw) return null;
|
||
|
||
// onecli 1.3+ emits JSON by default. Older versions would print raw text.
|
||
try {
|
||
const parsed = JSON.parse(raw) as { data?: unknown; value?: unknown };
|
||
const val = parsed.data ?? parsed.value;
|
||
if (typeof val === 'string' && val.trim()) {
|
||
return { version, apiHost: val.trim() };
|
||
}
|
||
} catch {
|
||
// not JSON — try to extract a URL directly
|
||
}
|
||
const m = raw.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
|
||
return m ? { version, apiHost: m[0] } : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function runInheritScript(cmd: string, args: string[]): Promise<number> {
|
||
return new Promise((resolve) => {
|
||
const child = spawn(cmd, args, { stdio: 'inherit' });
|
||
child.on('close', (code) => resolve(code ?? 1));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* After installing Docker, this process's supplementary groups are still
|
||
* frozen from login — subsequent steps that talk to /var/run/docker.sock
|
||
* (onecli install, service start, …) fail with EACCES even though the
|
||
* daemon is up. Detect that and re-exec the whole driver under `sg docker`
|
||
* so the rest of the run inherits the docker group without a re-login.
|
||
*/
|
||
function maybeReexecUnderSg(): void {
|
||
if (process.env.NANOCLAW_REEXEC_SG === '1') return;
|
||
if (process.platform !== 'linux') return;
|
||
const info = spawnSync('docker', ['info'], { encoding: 'utf-8' });
|
||
if (info.status === 0) return;
|
||
const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`;
|
||
if (!/permission denied/i.test(err)) return;
|
||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||
|
||
p.log.warn(brandBody('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' },
|
||
});
|
||
process.exit(res.status ?? 1);
|
||
}
|
||
|
||
// ─── intro + progression-log init ──────────────────────────────────────
|
||
|
||
function printIntro(): void {
|
||
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||
|
||
if (isReexec) {
|
||
p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
|
||
return;
|
||
}
|
||
|
||
// bash already printed the wordmark above us; the clack intro carries the
|
||
// welcome framing alone so the two don't double up. Standalone runs of
|
||
// setup:auto still see this as the first line — fine without the wordmark
|
||
// since the line itself signals the start of the flow.
|
||
p.intro("Let's get you set up.");
|
||
}
|
||
|
||
/**
|
||
* Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes
|
||
* the bootstrap entry before we even boot. If someone runs `pnpm run
|
||
* setup:auto` directly, start a fresh progression log here so we don't
|
||
* append to a stale one from a previous run.
|
||
*/
|
||
function initProgressionLog(): void {
|
||
if (process.env.NANOCLAW_BOOTSTRAPPED === '1') return;
|
||
let commit = '';
|
||
try {
|
||
commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {
|
||
encoding: 'utf-8',
|
||
}).stdout.trim();
|
||
} catch {
|
||
// git not available or not a repo — skip
|
||
}
|
||
let branch = '';
|
||
try {
|
||
branch = spawnSync('git', ['branch', '--show-current'], {
|
||
encoding: 'utf-8',
|
||
}).stdout.trim();
|
||
} catch {
|
||
// skip
|
||
}
|
||
setupLog.reset({
|
||
invocation: 'setup:auto (standalone)',
|
||
user: process.env.USER ?? 'unknown',
|
||
cwd: process.cwd(),
|
||
branch: branch || 'unknown',
|
||
commit: commit || 'unknown',
|
||
});
|
||
}
|
||
|
||
main().catch((err) => {
|
||
p.log.error(err instanceof Error ? err.message : String(err));
|
||
p.cancel('Setup aborted.');
|
||
process.exit(1);
|
||
});
|