Files
nanoclaw/setup/auto.ts
gavrielc 9b6e5b24a1 feat(setup): optional Discord wiring in setup:auto
Mirror of the Telegram flow but without a pairing step — Discord
exposes enough via the bot token that we only need one paste from the
operator, with every other identity field derived:

  GET /users/@me                       → bot username (sanity check)
  GET /oauth2/applications/@me         → application id, verify_key
                                         (public key), owner {id, username}
  POST /users/@me/channels             → DM channel id

After confirming "Is @<owner_username> your Discord account?" the flow
invites the bot to a server (OAuth URL + open + confirm, gating so the
welcome DM can actually reach the operator), installs the adapter, opens
the DM channel, and hands off to init-first-agent with
--channel discord --platform-id discord:@me:<dmChannelId>. The existing
init-first-agent welcome-over-CLI-socket path delivers the greeting
through the normal adapter pipeline — no Discord-specific code in the
welcome logic.

Fallbacks: if the app is team-owned (no owner object) or the operator
declines the confirmation, a Dev Mode walkthrough + user-id paste prompt
takes over.

Adds:
- setup/add-discord.sh (non-interactive installer, mirror of
  add-telegram.sh minus pair-step registration)
- setup/channels/discord.ts (operator-facing flow)
- setup/auto.ts: Discord option in askChannelChoice + dispatch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:45:05 +03:00

505 lines
16 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|channel|verify)
*
* Timezone defaults to the host system's TZ. Run
* pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>
* later if autodetect is wrong.
*/
import { spawn, spawnSync } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { runDiscordChannel } from './channels/discord.js';
import { runTelegramChannel } from './channels/telegram.js';
import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { brandBold, brandChip } from './lib/theme.js';
const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now();
async function main(): Promise<void> {
printIntro();
initProgressionLog();
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) {
fail(
'environment',
"Your system doesn't look quite right.",
'See logs/setup-steps/ for details, then retry.',
);
}
}
if (!skip.has('container')) {
p.log.message(
k.dim(
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
),
);
const res = await runQuietStep('container', {
running: 'Preparing the sandbox your assistant runs in…',
done: 'Sandbox ready.',
failed: "Couldn't prepare the sandbox.",
});
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'runtime_not_available') {
fail(
'container',
"Docker isn't available.",
'Install Docker Desktop (or start it if already installed), then retry.',
);
}
if (err === 'docker_group_not_active') {
fail(
'container',
"Docker was just installed but your shell doesn't know yet.",
'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
);
}
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(
k.dim(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
),
);
const res = await runQuietStep('onecli', {
running: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
});
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
fail(
'onecli',
'OneCLI was installed but your shell needs to refresh to see it.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
);
}
fail(
'onecli',
`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) {
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) {
fail(
'service',
"Couldn't start NanoClaw.",
'See logs/nanoclaw.error.log for details.',
);
}
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn(
"NanoClaw's permissions need a tweak before it can reach Docker.",
);
p.log.message(
k.dim(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
' systemctl --user restart nanoclaw',
),
);
}
}
let displayName: string | undefined;
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
if (needsDisplayName) {
const fallback = process.env.USER?.trim() || 'Operator';
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
displayName = preset || (await askDisplayName(fallback));
}
if (!skip.has('cli-agent')) {
const res = await runQuietStep(
'cli-agent',
{
running: 'Setting up your terminal chat…',
done: 'Terminal chat ready. Try `pnpm run chat hi`.',
},
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
);
if (!res.ok) {
fail(
'cli-agent',
"Couldn't set up the terminal chat.",
`You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`,
);
}
}
if (!skip.has('channel')) {
const choice = await askChannelChoice();
if (choice === 'telegram') {
await runTelegramChannel(displayName!);
} else if (choice === 'discord') {
await runDiscordChannel(displayName!);
} else {
p.log.info(
"No messaging app for now. You can add one later (like Telegram, Discord, or Slack).",
);
}
}
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 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) {
p.note(notes.join('\n'), "What's left");
}
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');
p.note(nextSteps, 'Try these');
setupLog.complete(Date.now() - RUN_START);
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 ────────────────────────────────────
async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant call you?',
placeholder: fallback,
defaultValue: fallback,
}),
);
const value = (answer as string).trim() || fallback;
setupLog.userInput('display_name', value);
return value;
}
async function askChannelChoice(): Promise<'telegram' | 'discord' | 'skip'> {
const choice = ensureAnswer(
await p.select({
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: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
],
}),
);
setupLog.userInput('channel_choice', String(choice));
return choice as 'telegram' | 'discord' | 'skip';
}
// ─── 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;
}
}
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('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 isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1';
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) {
p.intro(
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
);
return;
}
// When we were called via nanoclaw.sh, the wordmark + subtitle were
// already printed in bash. Just open the clack gutter with a short,
// neutral intro so the flow continues without duplication.
if (isBootstrapped) {
p.intro(k.dim("Let's get you set up."));
return;
}
console.log();
console.log(` ${wordmark}`);
console.log(` ${k.dim('Setting up your personal AI assistant')}`);
p.intro(k.dim("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);
});