Merge v2 into channels

Picks up 105 commits from v2 (engage modes, sender/channel approval flows,
host-sweep heartbeat lifecycle, setup/onecli refactor, setup-flow docs,
DeliveryAddress/InboundEvent adapter contract changes).

Retires 9 deprecated skills that moved out of this branch's scope:
add-compact, add-gmail, add-image-vision, add-pdf-reader, add-reactions,
add-telegram-swarm, add-voice-transcription, channel-formatting,
use-local-whisper.

Preserves channels-branch code: all 20 channel adapters (discord, slack,
telegram, whatsapp, wechat, matrix, emacs, iMessage, github, linear, teams,
gchat, webex, resend, whatsapp-cloud + helpers) plus chat-sdk deps.

Conflicts resolved:
- package.json: combined channels' adapter deps with v2's telegram bump
  (^4.24.0 → 4.26.0) and new @clack/prompts + kleur.
- pnpm-lock.yaml: regenerated from v2 baseline via pnpm install.
- setup/pair-telegram.ts: took v2's version (new PAIR_TELEGRAM_CODE block
  protocol; channels' older PAIR_TELEGRAM_ISSUED design superseded).

Note: host TS build will fail on adapter type drift (adapter.ts renamed
InboundMessage → InboundEvent; chat-sdk-bridge.ts rewritten). Fix in
follow-up commits.
This commit is contained in:
gavrielc
2026-04-22 11:56:30 +03:00
147 changed files with 14364 additions and 2553 deletions
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
#
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
# server-invite step live in setup/channels/discord.ts. Credentials come in via
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
#
# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-discord/SKILL.md.
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
CHANNELS_BRANCH="origin/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_DISCORD ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-discord] $*" >&2; }
if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then
emit_status failed "DISCORD_BOT_TOKEN env var not set"
exit 1
fi
if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then
emit_status failed "DISCORD_APPLICATION_ID env var not set"
exit 1
fi
if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then
emit_status failed "DISCORD_PUBLIC_KEY env var not set"
exit 1
fi
need_install() {
[ ! -f src/channels/discord.ts ] && return 0
! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch origin channels >&2 2>/dev/null || {
emit_status failed "git fetch origin channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts
# Append self-registration import if missing.
if ! grep -q "^import './discord.js';" src/channels/index.ts; then
echo "import './discord.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates before this point, so bad values here
# would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|| true
;;
esac
# Give the Discord adapter a moment to finish gateway handshake before
# init-first-agent attempts delivery.
sleep 5
emit_status success
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
#
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
# restart the service, and open the bot's chat page in the local Telegram
# client. Non-interactive — the operator-facing "Create a bot" instructions
# and token paste live in setup/auto.ts. The token comes in via the
# TELEGRAM_BOT_TOKEN env var.
#
# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All
# chatty progress messages go to stderr so setup:auto's raw-log capture
# sees the full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
CHANNELS_BRANCH="origin/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local username=${BOT_USERNAME:-}
echo "=== NANOCLAW SETUP: ADD_TELEGRAM ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$username" ] && echo "BOT_USERNAME: ${username}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-telegram] $*" >&2; }
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
emit_status failed "TELEGRAM_BOT_TOKEN env var not set"
exit 1
fi
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
emit_status failed "token format invalid (expected <digits>:<chars>)"
exit 1
fi
need_install() {
[ ! -f src/channels/telegram.ts ] && return 0
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch origin channels >&2 2>/dev/null || {
emit_status failed "git fetch origin channels failed"
exit 1
}
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
# in this list — do not overwrite the local version with the channels copy.
log "Copying adapter files from ${CHANNELS_BRANCH}"
for f in \
src/channels/telegram.ts \
src/channels/telegram-pairing.ts \
src/channels/telegram-pairing.test.ts \
src/channels/telegram-markdown-sanitize.ts \
src/channels/telegram-markdown-sanitize.test.ts
do
git show "${CHANNELS_BRANCH}:$f" > "$f"
done
# Append self-registration import if missing.
if ! grep -q "^import './telegram.js';" src/channels/index.ts; then
echo "import './telegram.js';" >> src/channels/index.ts
fi
# Register pair-telegram step if not already in the STEPS map.
# Uses node (not sed) since sed's in-place + escape semantics differ
# between BSD (macOS) and GNU.
node -e '
const fs = require("fs");
const p = "setup/index.ts";
let s = fs.readFileSync(p, "utf-8");
if (!s.includes("\047pair-telegram\047")) {
s = s.replace(
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
"$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27),"
);
fs.writeFileSync(p, s);
}
'
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist token. auto.ts validates before this point, so a bad token here
# would be an internal bug rather than operator input.
touch .env
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
awk -v tok="$TELEGRAM_BOT_TOKEN" \
'/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
fi
# Look up the bot username (auto.ts already validated; we re-query here so
# standalone invocations still work).
INFO=$(curl -fsS --max-time 8 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
BOT_USERNAME=""
if echo "$INFO" | grep -q '"ok":true'; then
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
# Deep-link into the bot's chat so the user is already on the right screen
# when pair-telegram prints the code. Silent best-effort — runs under a
# spinner, any output (from `open` / `xdg-open`) goes to the raw log.
if [ -n "$BOT_USERNAME" ]; then
case "$(uname -s)" in
Darwin)
open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \
|| open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \
|| true
;;
Linux)
xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \
|| xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \
|| true
;;
esac
fi
log "Restarting service so the new adapter picks up the token…"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|| true
;;
esac
# Give the Telegram adapter a moment to finish starting before pair-telegram
# begins polling for the user's code message.
sleep 5
emit_status success
+186
View File
@@ -0,0 +1,186 @@
/**
* Step: auth — Verify or register an Anthropic credential in OneCLI.
*
* Modes:
* --check (default) Verify an Anthropic secret exists.
* --create --value <token> Create an Anthropic secret. Errors if one
* already exists unless --force is passed.
*
* The actual user-facing prompt (subscription vs API key, paste the token)
* stays in the /new-setup SKILL.md. This step is just the machine side:
* it calls `onecli secrets list` / `onecli secrets create` and emits a
* structured status block. The token value is never logged.
*/
import { execFileSync } from 'child_process';
import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
interface Args {
mode: 'check' | 'create';
value?: string;
force: boolean;
}
function childEnv(): NodeJS.ProcessEnv {
const parts = [LOCAL_BIN];
if (process.env.PATH) parts.push(process.env.PATH);
return { ...process.env, PATH: parts.join(path.delimiter) };
}
function parseArgs(args: string[]): Args {
let mode: 'check' | 'create' = 'check';
let value: string | undefined;
let force = false;
for (let i = 0; i < args.length; i++) {
const key = args[i];
const val = args[i + 1];
switch (key) {
case '--check':
mode = 'check';
break;
case '--create':
mode = 'create';
break;
case '--value':
value = val;
i++;
break;
case '--force':
force = true;
break;
}
}
if (mode === 'create' && !value) {
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'missing_value_for_create',
LOG: 'logs/setup.log',
});
process.exit(2);
}
return { mode, value, force };
}
interface OnecliSecret {
id: string;
name: string;
type: string;
hostPattern: string | null;
}
function listSecrets(): OnecliSecret[] {
const out = execFileSync('onecli', ['secrets', 'list'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
});
const parsed = JSON.parse(out) as { data?: unknown };
return Array.isArray(parsed.data) ? (parsed.data as OnecliSecret[]) : [];
}
function findAnthropicSecret(secrets: OnecliSecret[]): OnecliSecret | undefined {
return secrets.find((s) => s.type === 'anthropic');
}
function createAnthropicSecret(value: string): void {
// `value` is a credential — do not log it, do not echo, do not pass through a shell.
execFileSync(
'onecli',
[
'secrets',
'create',
'--name',
'Anthropic',
'--type',
'anthropic',
'--value',
value,
'--host-pattern',
'api.anthropic.com',
],
{
env: childEnv(),
stdio: ['ignore', 'ignore', 'pipe'],
},
);
}
export async function run(args: string[]): Promise<void> {
const { mode, value, force } = parseArgs(args);
let secrets: OnecliSecret[];
try {
secrets = listSecrets();
} catch (err) {
log.error('onecli secrets list failed', { err });
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'onecli_list_failed',
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
const existing = findAnthropicSecret(secrets);
if (mode === 'check') {
emitStatus('AUTH', {
SECRET_PRESENT: !!existing,
ANTHROPIC_OK: !!existing,
STATUS: existing ? 'success' : 'missing',
...(existing ? { SECRET_NAME: existing.name, SECRET_ID: existing.id } : {}),
LOG: 'logs/setup.log',
});
return;
}
// mode === 'create'
if (existing && !force) {
emitStatus('AUTH', {
SECRET_PRESENT: true,
STATUS: 'skipped',
REASON: 'anthropic_secret_already_exists',
SECRET_NAME: existing.name,
SECRET_ID: existing.id,
HINT: 'Re-run with --force to replace, or delete the existing secret first.',
LOG: 'logs/setup.log',
});
return;
}
try {
createAnthropicSecret(value!);
} catch (err) {
const e = err as { stderr?: string | Buffer; status?: number };
const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8') ?? '';
log.error('onecli secrets create failed', { status: e.status, stderr });
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'onecli_create_failed',
EXIT_CODE: e.status ?? -1,
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Re-verify
const updated = findAnthropicSecret(listSecrets());
emitStatus('AUTH', {
SECRET_PRESENT: !!updated,
ANTHROPIC_OK: !!updated,
CREATED: true,
STATUS: updated ? 'success' : 'failed',
...(updated ? { SECRET_NAME: updated.name, SECRET_ID: updated.id } : {}),
LOG: 'logs/setup.log',
});
}
+614
View File
@@ -0,0 +1,614 @@
/**
* 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|first-chat)
*
* 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 { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { brandBold, brandChip, dimWrap, wrapForGutter } 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(
dimWrap(
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
4,
),
);
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(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
),
);
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: 'Bringing your assistant online…',
done: 'Assistant wired up.',
},
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
);
if (!res.ok) {
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')) {
const ping = await confirmAssistantResponds();
if (ping === 'ok') {
await runFirstChat();
} else {
renderPingFailureNote(ping);
}
}
}
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(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, or Slack).',
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') {
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)/com.nanoclaw',
' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.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) {
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."));
}
// ─── 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 3060s — 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(label);
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
s.message(`${label} ${k.dim(`(${elapsed}s)`)}`);
}, 1000);
const result = await pingCliAgent();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
if (result === 'ok') {
s.stop(`Your assistant is ready. ${k.dim(`(${elapsed}s)`)}`);
} else {
const msg =
result === 'socket_error'
? "Couldn't reach the NanoClaw service."
: "Your assistant didn't reply in time.";
s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`, 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,
),
'',
k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'),
k.dim(' Linux: systemctl --user restart nanoclaw'),
].join('\n')
: wrapForGutter(
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
6,
);
p.note(body, 'Skipping the first chat');
}
/**
* 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.
*/
async function runFirstChat(): Promise<void> {
while (true) {
const answer = ensureAnswer(
await p.text({
message: 'Say something to your assistant',
placeholder: 'press Enter with nothing to continue',
}),
);
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('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 wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) {
p.intro(
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
);
return;
}
// Always include the wordmark inside the clack intro line. When bash ran
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
// above us; the small repeat is worth it to keep the brand anchored at
// the visible top of the clack session once the bash output scrolls away.
p.intro(`${wordmark} ${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);
});
+455
View File
@@ -0,0 +1,455 @@
/**
* Discord channel flow for setup:auto.
*
* `runDiscordChannel(displayName)` owns the full branch from "do you have a
* bot?" through the welcome DM:
*
* 1. Ask if they have a bot already; walk them through Dev Portal creation
* if not
* 2. Paste the bot token (clack password) — format-validated
* 3. GET /users/@me to confirm the token and resolve bot username
* 4. GET /oauth2/applications/@me to derive application_id, verify_key
* (public key), and owner — no separate paste needed in the common case
* 5. Confirm owner identity (falls back to a manual user-id prompt with
* Developer Mode instructions if declined or if the app is team-owned)
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
* 9. Ask for the messaging-agent name (defaulting to "Nano")
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
* DM through the normal delivery path
*
* All output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000)
// + Read Message History (0x10000) = 100416.
// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md.
const INVITE_PERMISSIONS = '100416';
interface AppInfo {
applicationId: string;
publicKey: string;
owner: { id: string; username: string } | null;
}
export async function runDiscordChannel(displayName: string): Promise<void> {
if (!(await askHasBotToken())) {
await walkThroughBotCreation();
}
const token = await collectDiscordToken();
const botUsername = await validateDiscordToken(token);
const app = await fetchApplicationInfo(token);
const ownerUserId = await resolveOwnerUserId(app.owner);
await promptInviteBot(app.applicationId, botUsername);
const install = await runQuietChild(
'discord-install',
'bash',
['setup/add-discord.sh'],
{
running: `Connecting Discord to @${botUsername}`,
done: 'Discord connected.',
},
{
env: {
DISCORD_BOT_TOKEN: token,
DISCORD_APPLICATION_ID: app.applicationId,
DISCORD_PUBLIC_KEY: app.publicKey,
},
extraFields: {
BOT_USERNAME: botUsername,
APPLICATION_ID: app.applicationId,
},
},
);
if (!install.ok) {
fail(
'discord-install',
"Couldn't connect Discord.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `discord:@me:${dmChannelId}`;
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'discord',
'--user-id', `discord:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
],
{
running: `Connecting ${agentName} to your Discord DMs…`,
done: `${agentName} is ready. Check Discord for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'discord',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
);
}
}
async function askHasBotToken(): Promise<boolean> {
const answer = ensureAnswer(
await p.select({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
{ value: 'no', label: "No, walk me through creating one" },
],
}),
);
return answer === 'yes';
}
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
p.note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
' 1. Click "New Application", give it a name (e.g. "NanoClaw")',
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
'',
k.dim(`Opening ${url}`),
].join('\n'),
'Create a Discord bot',
);
openUrl(url);
ensureAnswer(
await p.confirm({
message: "Got your bot token?",
initialValue: true,
}),
);
}
async function collectDiscordToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
// Discord bot tokens are base64url segments separated by dots.
// Be lenient on length; the real check is /users/@me.
if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) {
return "That doesn't look like a Discord bot token";
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'discord_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function validateDiscordToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
username?: string;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
});
return data.username;
}
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Discord didn't accept that token: ${reason}`, 1);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
fail(
'discord-validate',
"Discord didn't accept that token.",
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
});
fail(
'discord-validate',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function fetchApplicationInfo(token: string): Promise<AppInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Looking up your bot application…');
try {
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
verify_key?: string;
owner?: { id: string; username: string } | null;
team?: unknown;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: reason,
});
fail(
'discord-app-info',
"Couldn't read your Discord application details.",
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
data.owner && data.owner.id && data.owner.username
? { id: data.owner.id, username: data.owner.username }
: null;
setupLog.step('discord-app-info', 'success', Date.now() - start, {
APPLICATION_ID: data.id,
OWNER_USERNAME: owner?.username ?? '',
TEAM_OWNED: data.team ? 'true' : 'false',
});
return {
applicationId: data.id,
publicKey: data.verify_key,
owner,
};
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
});
fail(
'discord-app-info',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveOwnerUserId(
owner: { id: string; username: string } | null,
): Promise<string> {
if (owner) {
const confirmed = ensureAnswer(
await p.confirm({
message: `Is @${owner.username} your Discord account?`,
initialValue: true,
}),
);
if (confirmed === true) {
setupLog.userInput('discord_owner_confirmed', owner.username);
return owner.id;
}
} else {
p.log.info(
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
p.note(
[
"To get your Discord user ID:",
'',
' 1. Open Discord → Settings (⚙️) → Advanced',
' 2. Turn on "Developer Mode"',
' 3. Right-click your own name/avatar → "Copy User ID"',
].join('\n'),
'Find your Discord user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Discord user ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'User ID is required';
if (!/^\d{17,20}$/.test(t)) {
return "That doesn't look like a Discord user ID (17-20 digits)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('discord_user_id', id);
return id;
}
async function promptInviteBot(
applicationId: string,
botUsername: string,
): Promise<void> {
const url =
`https://discord.com/api/oauth2/authorize` +
`?client_id=${applicationId}` +
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
p.note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
'',
k.dim(`Opening ${url}`),
].join('\n'),
'Add bot to a server',
);
openUrl(url);
ensureAnswer(
await p.confirm({
message: "I've added the bot to a server",
initialValue: true,
}),
);
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${DISCORD_API}/users/@me/channels`, {
method: 'POST',
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
fail(
'discord-open-dm',
"Couldn't open a DM channel with you.",
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
fail(
'discord-open-dm',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
function openUrl(url: string): void {
try {
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
const child = spawn(cmd, [url], { stdio: 'ignore', detached: true });
child.on('error', () => {
// Headless / no browser / unknown command — the URL is already
// printed in the note above, so the user can copy-paste.
});
child.unref();
} catch {
// swallow — URL is visible in the note.
}
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Telegram channel flow for setup:auto.
*
* `runTelegramChannel(displayName)` owns the full branch from the
* BotFather instructions through the welcome DM:
*
* 1. BotFather instructions (clack note)
* 2. Paste the bot token (clack password) — format-validated
* 3. getMe via the Bot API to resolve the bot's username
* 4. Install the adapter (setup/add-telegram.sh, non-interactive)
* 5. Run the pair-telegram step, rendering code events as clack notes
* 6. Ask for the messaging-agent name (defaulting to "Nano")
* 7. Wire the agent via scripts/init-first-agent.ts
*
* All output obeys the three-level contract: clack UI for the user,
* structured entries in logs/setup.log, full raw output in per-step files
* under logs/setup-steps/. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { brandBold } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runTelegramChannel(displayName: string): Promise<void> {
const token = await collectTelegramToken();
const botUsername = await validateTelegramToken(token);
const install = await runQuietChild(
'telegram-install',
'bash',
['setup/add-telegram.sh'],
{
running: `Connecting Telegram to @${botUsername}`,
done: 'Telegram connected.',
},
{
env: { TELEGRAM_BOT_TOKEN: token },
extraFields: { BOT_USERNAME: botUsername },
},
);
if (!install.ok) {
fail(
'telegram-install',
"Couldn't connect Telegram.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const pair = await runPairTelegram();
if (!pair.ok) {
fail(
'pair-telegram',
"Couldn't pair with Telegram.",
'Re-run setup to try again.',
);
}
const platformId = pair.terminal?.fields.PLATFORM_ID;
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
if (!platformId || !pairedUserId) {
fail(
'pair-telegram',
'Pairing completed but came back incomplete.',
'Re-run setup to try again.',
);
}
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'telegram',
'--user-id', pairedUserId,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
],
{
running: `Connecting ${agentName} to your Telegram chat…`,
done: `${agentName} is ready. Check Telegram for a welcome message.`,
},
{
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
},
);
if (!init.ok) {
fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function collectTelegramToken(): Promise<string> {
p.note(
[
"Your assistant talks to you through a Telegram bot you create.",
"Here's how:",
'',
' 1. Open Telegram and message @BotFather',
' 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'),
'Set up your Telegram bot',
);
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
validate: (v) => {
if (!v || !v.trim()) return "Token is required";
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
return "That doesn't look right. It should be <digits>:<chars>";
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'telegram_token',
`${token.slice(0, 12)}${token.slice(-4)}`,
);
return token;
}
async function validateTelegramToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
const data = (await res.json()) as {
ok?: boolean;
result?: { username?: string; id?: number };
description?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.result?.username) {
const username = data.result.username;
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
});
return username;
}
const reason = data.description ?? 'token rejected by Telegram';
s.stop(`Telegram didn't accept that token: ${reason}`, 1);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
fail(
'telegram-validate',
"Telegram didn't accept that token.",
'Copy the token again from @BotFather and try setup once more.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
});
fail(
'telegram-validate',
"Couldn't reach Telegram.",
'Check your internet connection and retry setup.',
);
}
}
async function runPairTelegram(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('pair-telegram');
const start = Date.now();
const s = p.spinner();
s.start('Generating a secret code for your bot…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number) => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
const result = await spawnStep(
'pair-telegram',
['--intent', 'main'],
(block: Block) => {
if (block.type === 'PAIR_TELEGRAM_CODE') {
const reason = block.fields.REASON ?? 'initial';
if (reason === 'initial') {
stopSpinner('Your secret code is ready.');
} else {
stopSpinner("Old code expired. Here's a fresh one.");
}
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start('Waiting for you to send the code from Telegram…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
s.start('Waiting for the correct code…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
stopSpinner('Telegram paired.');
} else {
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
// Safety net: if the child died without emitting a terminal block, make
// sure we don't leave the spinner running.
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Pairing ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('pair-telegram', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
function formatCodeCard(code: string): string {
const spaced = code.split('').join(' ');
return [
'',
` ${brandBold(spaced)}`,
'',
k.dim(' Send this code to your bot from Telegram.'),
].join('\n');
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
*
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
* /new-setup SKILL.md can parse the result without having to read the
* script's plain stdout.
*
* Args:
* --display-name <name> (required) operator's display name
* --agent-name <name> (optional) agent persona name, defaults to display-name
*/
import { execFileSync } from 'child_process';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): {
displayName: string;
agentName?: string;
} {
let displayName: string | undefined;
let agentName: string | undefined;
for (let i = 0; i < args.length; i++) {
const key = args[i];
const val = args[i + 1];
switch (key) {
case '--display-name':
displayName = val;
i++;
break;
case '--agent-name':
agentName = val;
i++;
break;
}
}
if (!displayName) {
emitStatus('CLI_AGENT', {
STATUS: 'failed',
ERROR: 'missing_display_name',
LOG: 'logs/setup.log',
});
process.exit(2);
}
return { displayName, agentName };
}
export async function run(args: string[]): Promise<void> {
const { displayName, agentName } = parseArgs(args);
const projectRoot = process.cwd();
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
if (agentName) scriptArgs.push('--agent-name', agentName);
log.info('Invoking init-cli-agent', { displayName, agentName });
try {
execFileSync('pnpm', scriptArgs, {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8',
});
} catch (err) {
const e = err as { stdout?: string; stderr?: string; status?: number };
log.error('init-cli-agent failed', {
status: e.status,
stdout: e.stdout,
stderr: e.stderr,
});
emitStatus('CLI_AGENT', {
STATUS: 'failed',
ERROR: 'init_script_failed',
EXIT_CODE: e.status ?? -1,
LOG: 'logs/setup.log',
});
process.exit(1);
}
emitStatus('CLI_AGENT', {
DISPLAY_NAME: displayName,
AGENT_NAME: agentName || displayName,
CHANNEL: 'cli/local',
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
+123 -63
View File
@@ -2,15 +2,73 @@
* Step: container — Build container image and verify with test run.
* Replaces 03-setup-container.sh
*/
import { execSync } from 'child_process';
import { execSync, spawnSync } from 'child_process';
import path from 'path';
import { setTimeout as sleep } from 'timers/promises';
import { log } from '../src/log.js';
import { commandExists } from './platform.js';
import { commandExists, getPlatform } from './platform.js';
import { emitStatus } from './status.js';
type DockerStatus = 'ok' | 'no-permission' | 'no-daemon' | 'other';
function dockerStatus(): DockerStatus {
const res = spawnSync('docker', ['info'], { encoding: 'utf-8' });
if (res.status === 0) return 'ok';
const err = `${res.stderr ?? ''}\n${res.stdout ?? ''}`;
if (/permission denied/i.test(err)) return 'no-permission';
if (/cannot connect|is the docker daemon running|no such file/i.test(err)) return 'no-daemon';
return 'other';
}
function dockerRunning(): boolean {
return dockerStatus() === 'ok';
}
/**
* Try to start Docker if it's installed but idle. Poll up to 60s for the
* daemon to come up — but bail immediately if the socket is reachable and
* only blocked by a group-permission error, since that won't resolve by
* waiting (the caller handles the sg re-exec for that case).
*/
async function tryStartDocker(): Promise<DockerStatus> {
const platform = getPlatform();
log.info('Docker not running — attempting to start', { platform });
try {
if (platform === 'macos') {
execSync('open -a Docker', { stdio: 'ignore' });
} else if (platform === 'linux') {
// Inherit stdio so sudo can prompt for a password if needed.
execSync('sudo systemctl start docker', { stdio: 'inherit' });
} else {
return 'other';
}
} catch (err) {
log.warn('Start command failed', { err });
return 'other';
}
for (let i = 0; i < 30; i++) {
await sleep(2000);
const s = dockerStatus();
if (s === 'ok') {
log.info('Docker is up');
return 'ok';
}
if (s === 'no-permission') {
log.info('Docker daemon is up but socket is not accessible (group membership)');
return 'no-permission';
}
}
log.warn('Docker did not become ready within 60s');
return 'no-daemon';
}
function parseArgs(args: string[]): { runtime: string } {
let runtime = '';
// `--runtime` is still accepted for backwards compatibility with the /setup
// skill, but `docker` is the only supported value.
let runtime = 'docker';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--runtime' && args[i + 1]) {
runtime = args[i + 1];
@@ -26,63 +84,7 @@ export async function run(args: string[]): Promise<void> {
const image = 'nanoclaw-agent:latest';
const logFile = path.join(projectRoot, 'logs', 'setup.log');
if (!runtime) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: 'unknown',
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'missing_runtime_flag',
LOG: 'logs/setup.log',
});
process.exit(4);
}
// Validate runtime availability
if (runtime === 'apple-container' && !commandExists('container')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'runtime_not_available',
LOG: 'logs/setup.log',
});
process.exit(2);
}
if (runtime === 'docker') {
if (!commandExists('docker')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'runtime_not_available',
LOG: 'logs/setup.log',
});
process.exit(2);
}
try {
execSync('docker info', { stdio: 'ignore' });
} catch {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'runtime_not_available',
LOG: 'logs/setup.log',
});
process.exit(2);
}
}
if (!['apple-container', 'docker'].includes(runtime)) {
if (runtime !== 'docker') {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
@@ -95,9 +97,67 @@ export async function run(args: string[]): Promise<void> {
process.exit(4);
}
const buildCmd =
runtime === 'apple-container' ? 'container build' : 'docker build';
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
if (!commandExists('docker')) {
log.info('Docker not found — running setup/install-docker.sh');
try {
execSync('bash setup/install-docker.sh', { cwd: projectRoot, stdio: 'inherit' });
} catch (err) {
log.warn('install-docker.sh failed', { err });
}
}
if (!commandExists('docker')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'runtime_not_available',
LOG: 'logs/setup.log',
});
process.exit(2);
}
{
let status = dockerStatus();
if (status !== 'ok') {
status = await tryStartDocker();
}
// Socket is unreachable due to group perms — current shell's supplementary
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
// or a prior install) doesn't affect us until next login. Re-exec this
// step under `sg docker` so the child picks up docker as its primary
// group and can talk to /var/run/docker.sock without a logout.
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
log.info('Re-executing container step under `sg docker`');
const res = spawnSync(
'sg',
['docker', '-c', 'pnpm exec tsx setup/index.ts --step container'],
{ cwd: projectRoot, stdio: 'inherit' },
);
process.exit(res.status ?? 1);
}
if (status !== 'ok') {
const error =
status === 'no-permission' ? 'docker_group_not_active' : 'runtime_not_available';
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: error,
LOG: 'logs/setup.log',
});
process.exit(2);
}
}
const buildCmd = 'docker build';
const runCmd = 'docker';
// Build-args from .env. Only INSTALL_CJK_FONTS is passed through today.
// Keeps /setup and ./container/build.sh in sync — both read the same source.
-8
View File
@@ -21,12 +21,6 @@ export async function run(_args: string[]): Promise<void> {
const wsl = isWSL();
const headless = isHeadless();
// Check Apple Container
let appleContainer: 'installed' | 'not_found' = 'not_found';
if (commandExists('container')) {
appleContainer = 'installed';
}
// Check Docker
let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found';
if (commandExists('docker')) {
@@ -78,7 +72,6 @@ export async function run(_args: string[]): Promise<void> {
{
platform,
wsl,
appleContainer,
docker,
hasEnv,
hasAuth,
@@ -91,7 +84,6 @@ export async function run(_args: string[]): Promise<void> {
PLATFORM: platform,
IS_WSL: wsl,
IS_HEADLESS: headless,
APPLE_CONTAINER: appleContainer,
DOCKER: docker,
HAS_ENV: hasEnv,
HAS_AUTH: hasAuth,
+4
View File
@@ -10,6 +10,7 @@ const STEPS: Record<
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
timezone: () => import('./timezone.js'),
'set-env': () => import('./set-env.js'),
environment: () => import('./environment.js'),
container: () => import('./container.js'),
groups: () => import('./groups.js'),
@@ -19,6 +20,9 @@ const STEPS: Record<
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
onecli: () => import('./onecli.js'),
auth: () => import('./auth.js'),
'cli-agent': () => import('./cli-agent.js'),
};
async function main(): Promise<void> {
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Install the Claude Code CLI on the host via the official native installer.
# Invoked from setup/register-claude-token.sh when the user picks the
# subscription auth path and `claude` is missing. The other two auth paths
# (paste OAuth token, paste API key) don't need the CLI, so this runs on
# demand rather than up front.
#
# The native installer is Node-independent (downloads a prebuilt binary to
# ~/.local/bin) and is the path Anthropic documents. This matches the
# pattern used by install-docker.sh / install-node.sh: the script itself is
# the allowlisted unit; the curl | bash pipe lives inside it.
set -euo pipefail
echo "=== NANOCLAW SETUP: INSTALL_CLAUDE ==="
if command -v claude >/dev/null 2>&1; then
echo "STATUS: already-installed"
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
echo "=== END ==="
exit 0
fi
if ! command -v curl >/dev/null 2>&1; then
echo "STATUS: failed"
echo "ERROR: curl not available."
echo "=== END ==="
exit 1
fi
echo "STEP: claude-native-install"
curl -fsSL https://claude.ai/install.sh | bash
# Native installer writes to ~/.local/bin and appends a PATH line to the
# user's rc file; that doesn't help this session, so put it on PATH now.
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
export PATH="$HOME/.local/bin:$PATH"
fi
hash -r 2>/dev/null || true
if ! command -v claude >/dev/null 2>&1; then
echo "STATUS: failed"
echo "ERROR: claude not found on PATH after install."
echo "=== END ==="
exit 1
fi
echo "STATUS: installed"
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-discord — bundles the preflight + install commands
# from the /add-discord skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Discord adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/discord package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
needs_install=false
[[ -f src/channels/discord.ts ]] || needs_install=true
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
echo "STEP: register-import"
if ! grep -q "import './discord.js';" src/channels/index.ts; then
printf "import './discord.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/discord@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Setup helper: install-docker — bundles Docker install into one idempotent
# script so /new-setup can run it without needing `curl | sh` in the allowlist
# (pipelines split at matching time, and `sh` receiving stdin can't be
# pre-approved safely).
#
# The script itself is the allowlisted unit; the pipes and sudo live inside
# it. Starting the daemon (after install) stays separate — `open -a Docker`
# and `sudo systemctl start docker` are already in the allowlist.
set -euo pipefail
echo "=== NANOCLAW SETUP: INSTALL_DOCKER ==="
if command -v docker >/dev/null 2>&1; then
echo "STATUS: already-installed"
echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)"
echo "=== END ==="
exit 0
fi
case "$(uname -s)" in
Darwin)
echo "STEP: brew-install-docker"
if ! command -v brew >/dev/null 2>&1; then
echo "STATUS: failed"
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
echo "=== END ==="
exit 1
fi
brew install --cask docker
;;
Linux)
echo "STEP: docker-get-script"
curl -fsSL https://get.docker.com | sh
echo "STEP: usermod-docker-group"
sudo usermod -aG docker "$USER"
echo "NOTE: you may need to log out and back in for docker group membership to take effect"
;;
*)
echo "STATUS: failed"
echo "ERROR: Unsupported platform: $(uname -s)"
echo "=== END ==="
exit 1
;;
esac
if ! command -v docker >/dev/null 2>&1; then
echo "STATUS: failed"
echo "ERROR: docker not found on PATH after install"
echo "=== END ==="
exit 1
fi
echo "STATUS: installed"
echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-gchat — bundles the preflight + install commands
# from the /add-gchat skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Google Chat adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/gchat package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_GCHAT ==="
needs_install=false
[[ -f src/channels/gchat.ts ]] || needs_install=true
grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/gchat"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
echo "STEP: register-import"
if ! grep -q "import './gchat.js';" src/channels/index.ts; then
printf "import './gchat.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/gchat@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-github — bundles the preflight + install commands
# from the /add-github skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the GitHub adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/github package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_GITHUB ==="
needs_install=false
[[ -f src/channels/github.ts ]] || needs_install=true
grep -q "import './github.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/github"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/github ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/github.ts > src/channels/github.ts
echo "STEP: register-import"
if ! grep -q "import './github.js';" src/channels/index.ts; then
printf "import './github.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/github@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Setup helper: install-imessage — bundles the preflight + install commands
# from the /add-imessage skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the iMessage adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned chat-adapter-imessage package;
# builds. Local vs remote mode pick stays in the skill — this script only
# handles the deterministic install. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ==="
needs_install=false
[[ -f src/channels/imessage.ts ]] || needs_install=true
grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true
grep -q '"chat-adapter-imessage"' package.json || needs_install=true
[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
echo "STEP: register-import"
if ! grep -q "import './imessage.js';" src/channels/index.ts; then
printf "import './imessage.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install chat-adapter-imessage@0.1.1
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Setup helper: install-linear — bundles the preflight + install commands
# from the /add-linear skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Linear adapter in from the `channels` branch; appends the
# self-registration import; patches src/channels/chat-sdk-bridge.ts to add
# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the
# onNewMention handler never fires — the bridge needs a catchAll path);
# installs the pinned @chat-adapter/linear package; builds. All steps are
# safe to re-run.
#
# Note: the bridge patch's onNewMessage handler passes `false` for isMention
# (current trunk signature requires the arg). The /add-linear SKILL's
# snippet omits the arg — this script uses the full signature so TypeScript
# builds cleanly.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_LINEAR ==="
needs_install=false
[[ -f src/channels/linear.ts ]] || needs_install=true
grep -q "import './linear.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/linear"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true
grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
echo "STEP: register-import"
if ! grep -q "import './linear.js';" src/channels/index.ts; then
printf "import './linear.js';\n" >> src/channels/index.ts
fi
echo "STEP: patch-bridge-catchall-field"
if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then
awk '
/^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 }
in_iface && /^\}/ && !inserted {
print " /**"
print " * Forward ALL messages in unsubscribed threads, not just @-mentions."
print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g."
print " * Linear OAuth apps). The thread is auto-subscribed on first message."
print " */"
print " catchAll?: boolean;"
inserted = 1
in_iface = 0
}
{ print }
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
fi
echo "STEP: patch-bridge-catchall-handler"
if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
awk '
/ \/\/ DMs — apply engage rules too/ && !inserted {
print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear"
print " // OAuth apps). Forward every unsubscribed message and auto-subscribe."
print " if (config.catchAll) {"
print " chat.onNewMessage(/.*/, async (thread, message) => {"
print " const channelId = adapter.channelIdFromThreadId(thread.id);"
print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));"
print " await thread.subscribe();"
print " });"
print " }"
print ""
inserted = 1
}
{ print }
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/linear@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Setup helper: install-matrix — bundles the preflight + install commands
# from the /add-matrix skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Matrix adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @beeper/chat-adapter-matrix
# package; patches the adapter's published dist so its matrix-js-sdk/lib
# imports carry .js extensions (required under Node 22 strict ESM); builds.
# All steps are safe to re-run — re-run this script after any pnpm install
# that touches the adapter.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_MATRIX ==="
needs_install=false
[[ -f src/channels/matrix.ts ]] || needs_install=true
grep -q "import './matrix.js';" src/channels/index.ts || needs_install=true
grep -q '"@beeper/chat-adapter-matrix"' package.json || needs_install=true
[[ -d node_modules/@beeper/chat-adapter-matrix ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
echo "STEP: register-import"
if ! grep -q "import './matrix.js';" src/channels/index.ts; then
printf "import './matrix.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @beeper/chat-adapter-matrix@0.2.0
echo "STEP: patch-esm-extensions"
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
));
console.log("Patched", f);
'
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Setup helper: install-node — bundles Node 22 install into one idempotent
# script so /new-setup can run it without needing `curl | sudo -E bash -` in
# the allowlist (that pattern is inherently unmatchable — bash reads from
# stdin, so pre-approval can't inspect what's being executed).
#
# The script itself is the allowlisted unit; the pipes and sudo live inside
# it. Pure bash by design — runs before Node exists on the host.
set -euo pipefail
echo "=== NANOCLAW SETUP: INSTALL_NODE ==="
if command -v node >/dev/null 2>&1; then
echo "STATUS: already-installed"
echo "NODE_VERSION: $(node --version)"
echo "=== END ==="
exit 0
fi
case "$(uname -s)" in
Darwin)
echo "STEP: brew-install-node"
if ! command -v brew >/dev/null 2>&1; then
echo "STATUS: failed"
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
echo "=== END ==="
exit 1
fi
brew install node@22
;;
Linux)
echo "STEP: nodesource-setup"
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
echo "STEP: apt-install-nodejs"
sudo apt-get install -y nodejs
;;
*)
echo "STATUS: failed"
echo "ERROR: Unsupported platform: $(uname -s)"
echo "=== END ==="
exit 1
;;
esac
if ! command -v node >/dev/null 2>&1; then
echo "STATUS: failed"
echo "ERROR: node not found on PATH after install"
echo "=== END ==="
exit 1
fi
echo "STATUS: installed"
echo "NODE_VERSION: $(node --version)"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-resend — bundles the preflight + install commands
# from the /add-resend skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Resend adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @resend/chat-sdk-adapter
# package; builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_RESEND ==="
needs_install=false
[[ -f src/channels/resend.ts ]] || needs_install=true
grep -q "import './resend.js';" src/channels/index.ts || needs_install=true
grep -q '"@resend/chat-sdk-adapter"' package.json || needs_install=true
[[ -d node_modules/@resend/chat-sdk-adapter ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
echo "STEP: register-import"
if ! grep -q "import './resend.js';" src/channels/index.ts; then
printf "import './resend.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @resend/chat-sdk-adapter@0.1.1
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-slack — bundles the preflight + install commands
# from the /add-slack skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Slack adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/slack package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
needs_install=false
[[ -f src/channels/slack.ts ]] || needs_install=true
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
echo "STEP: register-import"
if ! grep -q "import './slack.js';" src/channels/index.ts; then
printf "import './slack.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/slack@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-teams — bundles the preflight + install commands
# from the /add-teams skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Teams adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/teams package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_TEAMS ==="
needs_install=false
[[ -f src/channels/teams.ts ]] || needs_install=true
grep -q "import './teams.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/teams"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/teams ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
echo "STEP: register-import"
if ! grep -q "import './teams.js';" src/channels/index.ts; then
printf "import './teams.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/teams@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Setup helper: install-telegram — bundles the preflight + install commands
# from the /add-telegram skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials and pairing.
#
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
# step in from the `channels` branch; appends the self-registration import;
# registers the `pair-telegram` entry in the setup STEPS map; installs the
# pinned @chat-adapter/telegram package; builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_TELEGRAM ==="
CHANNEL_FILES=(
src/channels/telegram.ts
src/channels/telegram-pairing.ts
src/channels/telegram-pairing.test.ts
src/channels/telegram-markdown-sanitize.ts
src/channels/telegram-markdown-sanitize.test.ts
setup/pair-telegram.ts
)
needs_install=false
for f in "${CHANNEL_FILES[@]}"; do
[[ -f "$f" ]] || needs_install=true
done
grep -q "import './telegram.js';" src/channels/index.ts || needs_install=true
grep -q "'pair-telegram':" setup/index.ts || needs_install=true
grep -q '"@chat-adapter/telegram"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/telegram ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
for f in "${CHANNEL_FILES[@]}"; do
git show "origin/channels:$f" > "$f"
done
echo "STEP: register-import"
if ! grep -q "import './telegram.js';" src/channels/index.ts; then
printf "import './telegram.js';\n" >> src/channels/index.ts
fi
echo "STEP: register-setup-step"
if ! grep -q "'pair-telegram':" setup/index.ts; then
awk '
{ print }
/register: \(\) => import/ && !inserted {
print " '\''pair-telegram'\'': () => import('\''./pair-telegram.js'\''),"
inserted = 1
}
' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/telegram@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-webex — bundles the preflight + install commands
# from the /add-webex skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Webex adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @bitbasti/chat-adapter-webex
# package; builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_WEBEX ==="
needs_install=false
[[ -f src/channels/webex.ts ]] || needs_install=true
grep -q "import './webex.js';" src/channels/index.ts || needs_install=true
grep -q '"@bitbasti/chat-adapter-webex"' package.json || needs_install=true
[[ -d node_modules/@bitbasti/chat-adapter-webex ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
echo "STEP: register-import"
if ! grep -q "import './webex.js';" src/channels/index.ts; then
printf "import './webex.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @bitbasti/chat-adapter-webex@0.1.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
# commands from the /add-whatsapp-cloud skill into one idempotent script so
# /new-setup can run them programmatically before continuing to credentials.
#
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_WHATSAPP_CLOUD ==="
needs_install=false
[[ -f src/channels/whatsapp-cloud.ts ]] || needs_install=true
grep -q "import './whatsapp-cloud.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/whatsapp"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/whatsapp ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
echo "STEP: register-import"
if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
printf "import './whatsapp-cloud.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/whatsapp@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp — bundles the preflight + install commands
# from the /add-whatsapp skill into one idempotent script so /new-setup can
# run them programmatically before continuing to QR/pairing-code auth.
#
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
# setup steps in from the `channels` branch; appends the self-registration
# import; registers `groups` and `whatsapp-auth` entries in the setup STEPS
# map; installs the pinned @whiskeysockets/baileys + qrcode + pino packages;
# builds. All steps are safe to re-run. QR/pairing-code authentication
# stays in the skill — this script only handles the deterministic install.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_WHATSAPP ==="
CHANNEL_FILES=(
src/channels/whatsapp.ts
setup/whatsapp-auth.ts
setup/groups.ts
)
needs_install=false
for f in "${CHANNEL_FILES[@]}"; do
[[ -f "$f" ]] || needs_install=true
done
grep -q "import './whatsapp.js';" src/channels/index.ts || needs_install=true
grep -q "groups: " setup/index.ts || needs_install=true
grep -q "'whatsapp-auth':" setup/index.ts || needs_install=true
grep -q '"@whiskeysockets/baileys"' package.json || needs_install=true
grep -q '"qrcode"' package.json || needs_install=true
grep -q '"pino"' package.json || needs_install=true
[[ -d node_modules/@whiskeysockets/baileys ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
for f in "${CHANNEL_FILES[@]}"; do
git show "origin/channels:$f" > "$f"
done
echo "STEP: register-import"
if ! grep -q "import './whatsapp.js';" src/channels/index.ts; then
printf "import './whatsapp.js';\n" >> src/channels/index.ts
fi
echo "STEP: register-setup-steps"
if ! grep -q "'whatsapp-auth':" setup/index.ts; then
awk '
{ print }
/register: \(\) => import/ && !inserted {
print " groups: () => import('\''./groups.js'\''),"
print " '\''whatsapp-auth'\'': () => import('\''./whatsapp-auth.js'\''),"
inserted = 1
}
' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+50
View File
@@ -0,0 +1,50 @@
/**
* Round-trip check against the CLI Unix socket.
*
* Shared by `setup/verify.ts` (end-of-run health check) and `setup/auto.ts`
* (confirm the freshly-wired agent actually responds before prompting the
* user to chat with it).
*
* Exit-code contract follows `scripts/chat.ts`:
* 0 → got a reply on stdout
* 2 → socket unreachable (service not running or wrong checkout)
* 3 → no reply before chat.ts's own 120s hard stop
* This wrapper also guards with its own timeout in case chat.ts hangs.
*/
import { spawn } from 'child_process';
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
return new Promise((resolve) => {
const child = spawn('pnpm', ['run', 'chat', 'ping'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve('no_reply');
}, timeoutMs);
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf-8');
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 2) resolve('socket_error');
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
else resolve('no_reply');
});
child.on('error', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve('socket_error');
});
});
}
+325
View File
@@ -0,0 +1,325 @@
/**
* Step runner + abort helpers for setup:auto.
*
* Responsibilities:
* - Stream-parse setup-step status blocks (`=== NANOCLAW SETUP: … ===`)
* - Spawn children with output tee'd to a per-step raw log (level 3)
* - Wrap each run in a clack spinner with live elapsed time (level 1)
* - Append a structured entry to the progression log (level 2) via
* `setup/logs.ts` when the run ends
* - Abort helpers (`fail`, `ensureAnswer`) used by step orchestrators
*
* See docs/setup-flow.md for the three-level output contract.
*/
import { spawn } from 'child_process';
import fs from 'fs';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
export type Fields = Record<string, string>;
export type Block = { type: string; fields: Fields };
export type StepResult = {
ok: boolean;
exitCode: number;
blocks: Block[];
transcript: string;
/** The last block with a STATUS field (the terminal/result block). */
terminal: Block | null;
};
export type QuietChildResult = {
ok: boolean;
exitCode: number;
transcript: string;
terminal: Block | null;
blocks: Block[];
};
export type SpinnerLabels = {
running: string;
done: string;
skipped?: string;
failed?: string;
};
/**
* Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each
* block as it closes so the UI can react mid-stream (e.g. render a pairing
* code card as soon as pair-telegram emits it, rather than after the step
* has finished).
*/
export class StatusStream {
private lineBuf = '';
private current: Block | null = null;
readonly blocks: Block[] = [];
transcript = '';
constructor(private readonly onBlock: (block: Block) => void) {}
write(chunk: string): void {
this.transcript += chunk;
this.lineBuf += chunk;
let idx: number;
while ((idx = this.lineBuf.indexOf('\n')) !== -1) {
const line = this.lineBuf.slice(0, idx);
this.lineBuf = this.lineBuf.slice(idx + 1);
this.processLine(line);
}
}
private processLine(line: string): void {
const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/);
if (start) {
this.current = { type: start[1], fields: {} };
return;
}
if (line.startsWith('=== END ===')) {
if (this.current) {
this.blocks.push(this.current);
this.onBlock(this.current);
this.current = null;
}
return;
}
if (!this.current) return;
const colon = line.indexOf(':');
if (colon === -1) return;
const key = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
if (key) this.current.fields[key] = value;
}
}
/**
* Spawn a setup step as a child process. Output is tee'd to the provided
* raw log file (level 3) and parsed for status blocks (level 2 summary).
* The onBlock callback fires per status block as they close so the UI can
* react mid-stream.
*/
export function spawnStep(
stepName: string,
extra: string[],
onBlock: (block: Block) => void,
rawLogPath: string,
): Promise<StepResult> {
return new Promise((resolve) => {
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
if (extra.length > 0) args.push('--', ...extra);
const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stream = new StatusStream(onBlock);
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
raw.write(`# ${stepName}${new Date().toISOString()}\n\n`);
child.stdout.on('data', (chunk: Buffer) => {
stream.write(chunk.toString('utf-8'));
raw.write(chunk);
});
child.stderr.on('data', (chunk: Buffer) => {
stream.transcript += chunk.toString('utf-8');
raw.write(chunk);
});
child.on('close', (code) => {
raw.end();
const terminal =
[...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null;
const status = terminal?.fields.STATUS;
const ok = code === 0 && (status === 'success' || status === 'skipped');
resolve({
ok,
exitCode: code ?? 1,
blocks: stream.blocks,
transcript: stream.transcript,
terminal,
});
});
});
}
export function spawnQuiet(
cmd: string,
args: string[],
rawLogPath: string,
envOverride?: NodeJS.ProcessEnv,
): Promise<QuietChildResult> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: envOverride ? { ...process.env, ...envOverride } : process.env,
});
let transcript = '';
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
raw.write(`# ${[cmd, ...args].join(' ')}${new Date().toISOString()}\n\n`);
const blocks: Block[] = [];
const stream = new StatusStream((b) => blocks.push(b));
child.stdout.on('data', (c: Buffer) => {
const s = c.toString('utf-8');
transcript += s;
stream.write(s);
raw.write(c);
});
child.stderr.on('data', (c: Buffer) => {
transcript += c.toString('utf-8');
raw.write(c);
});
child.on('close', (code) => {
raw.end();
const terminal =
[...blocks].reverse().find((b) => b.fields.STATUS) ?? null;
resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks });
});
});
}
/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */
export async function runQuietStep(
stepName: string,
labels: SpinnerLabels,
extra: string[] = [],
): Promise<StepResult & { rawLog: string; durationMs: number }> {
const rawLog = setupLog.stepRawLog(stepName);
const start = Date.now();
const result = await runUnderSpinner(labels, () =>
spawnStep(stepName, extra, () => {}, rawLog),
);
const durationMs = Date.now() - start;
writeStepEntry(stepName, result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */
export async function runQuietChild(
logName: string,
cmd: string,
args: string[],
labels: SpinnerLabels,
opts?: {
/** Extra fields to merge into the progression entry (on top of any status-block fields). */
extraFields?: Record<string, string | number | boolean>;
/** Environment overrides to pass to the child process. */
env?: NodeJS.ProcessEnv;
},
): Promise<QuietChildResult & { rawLog: string; durationMs: number }> {
const rawLog = setupLog.stepRawLog(logName);
const start = Date.now();
const result = await runUnderSpinner(labels, () =>
spawnQuiet(cmd, args, rawLog, opts?.env),
);
const durationMs = Date.now() - start;
const blockFields = summariseTerminalFields(result.terminal);
const fields = { ...blockFields, ...(opts?.extraFields ?? {}) };
const rawStatus = result.terminal?.fields.STATUS;
const status: 'success' | 'skipped' | 'failed' = !result.ok
? 'failed'
: rawStatus === 'skipped'
? 'skipped'
: 'success';
setupLog.step(logName, status, durationMs, fields, rawLog);
return { ...result, rawLog, durationMs };
}
/** Turn a step's terminal-block fields into a concise progression-log entry. */
export function writeStepEntry(
stepName: string,
result: StepResult,
durationMs: number,
rawLog: string,
): void {
const rawStatus = result.terminal?.fields.STATUS;
const logStatus: 'success' | 'skipped' | 'failed' = !result.ok
? 'failed'
: rawStatus === 'skipped'
? 'skipped'
: 'success';
const fields = summariseTerminalFields(result.terminal);
setupLog.step(stepName, logStatus, durationMs, fields, rawLog);
}
/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */
export function summariseTerminalFields(block: Block | null): Record<string, string> {
if (!block) return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(block.fields)) {
if (k === 'STATUS' || k === 'LOG') continue;
if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log
out[k] = v;
}
return out;
}
async function runUnderSpinner<
T extends { ok: boolean; transcript: string; terminal?: Block | null },
>(
labels: SpinnerLabels,
work: () => Promise<T>,
): Promise<T> {
const s = p.spinner();
const start = Date.now();
s.start(labels.running);
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`);
}, 1000);
const result = await work();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1);
dumpTranscriptOnFailure(result.transcript);
}
return result;
}
export function dumpTranscriptOnFailure(transcript: string): void {
const lines = transcript.split('\n').filter((l) => {
if (l.startsWith('=== NANOCLAW SETUP:')) return false;
if (l.startsWith('=== END ===')) return false;
return true;
});
const tail = lines.slice(-40).join('\n').trimEnd();
if (tail) {
console.log();
console.log(k.dim(tail));
console.log();
}
}
/**
* Abort the setup run with a user-facing error, logging the abort to the
* progression log. Takes the step name explicitly so callers are clear
* about which step they're failing from — no hidden module state.
*/
export function fail(stepName: string, msg: string, hint?: string): never {
setupLog.abort(stepName, msg);
p.log.error(msg);
if (hint) p.log.message(k.dim(hint));
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
p.cancel('Setup aborted.');
process.exit(1);
}
/**
* Unwrap a clack prompt result. If the user cancelled (Ctrl-C / Esc), exit
* gracefully. Cancel is exit 0 — it's not an abort worth logging to the
* progression log, since the operator initiated it deliberately.
*/
export function ensureAnswer<T>(value: T | symbol): T {
if (p.isCancel(value)) {
p.cancel('Setup cancelled.');
process.exit(0);
}
return value as T;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* NanoClaw brand palette for the terminal.
*
* Colors pulled from assets/nanoclaw-logo.png:
* brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body
* brand navy ≈ #171B3B — the dark logo background + outlines
*
* Rendering gates:
* - No TTY (piped / redirected) → plain text, no ANSI
* - NO_COLOR set → plain text, no ANSI
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
* - Otherwise → kleur's 16-color cyan (closest fallback)
*/
import k from 'kleur';
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
const TRUECOLOR =
USE_ANSI &&
(process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit');
export function brand(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`;
return k.cyan(s);
}
export function brandBold(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`;
return k.bold(k.cyan(s));
}
export function brandChip(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`;
}
return k.bgCyan(k.black(k.bold(s)));
}
/**
* Wrap text so it fits inside clack's gutter without the terminal's soft
* wrap breaking the `│ …` bar on long lines. Works on a single string with
* embedded `\n`s; each logical line is wrapped independently.
*
* The `gutter` argument is the total horizontal overhead clack adds for
* the component the text lives in (e.g. 4 for `p.log.*`'s `│ ` prefix;
* 6-ish for `p.note`'s box). Caller picks it; we just subtract from
* `process.stdout.columns` and hard-wrap at word boundaries.
*/
export function wrapForGutter(text: string, gutter: number): string {
const cols = process.stdout.columns ?? 80;
const width = Math.max(30, cols - gutter);
return text
.split('\n')
.map((line) => wrapLine(line, width))
.join('\n');
}
/**
* Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))`
* because clack resets styling at each line break when rendering
* multi-line log content — a single outer dim envelope only colors the
* first line. Applying dim per-line gives each wrapped row its own
* `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block.
*/
export function dimWrap(text: string, gutter: number): string {
return wrapForGutter(text, gutter)
.split('\n')
.map((line) => k.dim(line))
.join('\n');
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(s: string): number {
return s.replace(ANSI_RE, '').length;
}
function wrapLine(line: string, width: number): string {
if (visibleLength(line) <= width) return line;
const words = line.split(' ');
const rows: string[] = [];
let cur = '';
let curLen = 0;
for (const word of words) {
const wLen = visibleLength(word);
if (curLen === 0) {
cur = word;
curLen = wLen;
} else if (curLen + 1 + wLen <= width) {
cur += ' ' + word;
curLen += 1 + wLen;
} else {
rows.push(cur);
cur = word;
curLen = wLen;
}
}
if (cur) rows.push(cur);
return rows.join('\n');
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Three-level setup logging primitives. See docs/setup-flow.md for the
* contract and design rationale.
*
* Level 1: clack UI in setup/auto.ts (not here)
* Level 2: logs/setup.log — structured, append-only progression log
* Level 3: logs/setup-steps/NN-name.log — raw stdout+stderr per step
*
* Usage from auto.ts:
*
* import * as setupLog from './logs.js';
*
* const rawLog = setupLog.stepRawLog('container');
* const { ok, durationMs, terminal } =
* await spawnIntoRawLog('...', rawLog);
* setupLog.step('container', ok ? 'success' : 'failed', durationMs,
* { RUNTIME: 'docker', BUILD_OK: terminal.fields.BUILD_OK },
* rawLog);
*
* nanoclaw.sh emits the bootstrap entry directly via a bash helper so
* the format stays consistent without needing IPC between bash and tsx.
*/
import fs from 'fs';
import path from 'path';
const LOGS_DIR = 'logs';
const STEPS_DIR = path.join(LOGS_DIR, 'setup-steps');
const PROGRESS_LOG = path.join(LOGS_DIR, 'setup.log');
export const progressLogPath = PROGRESS_LOG;
export const stepsDir = STEPS_DIR;
/** Wipe prior logs and write a header. Called once per fresh run (by nanoclaw.sh or as a fallback by auto.ts if invoked standalone). */
export function reset(meta: Record<string, string>): void {
if (fs.existsSync(STEPS_DIR)) {
fs.rmSync(STEPS_DIR, { recursive: true, force: true });
}
fs.mkdirSync(STEPS_DIR, { recursive: true });
if (fs.existsSync(PROGRESS_LOG)) fs.unlinkSync(PROGRESS_LOG);
header(meta);
}
/** Append a run-start header to the progression log. Idempotent: creates the file if missing. */
export function header(meta: Record<string, string>): void {
fs.mkdirSync(LOGS_DIR, { recursive: true });
const ts = new Date().toISOString();
const lines = [`## ${ts} · setup:auto started`];
for (const [k, v] of Object.entries(meta)) {
lines.push(` ${k}: ${v}`);
}
lines.push('');
fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n');
}
/** Append one step entry to the progression log. */
export function step(
name: string,
status: 'success' | 'skipped' | 'failed' | 'aborted' | 'interactive',
durationMs: number,
fields: Record<string, string | number | boolean | undefined>,
rawRel?: string,
): void {
fs.mkdirSync(LOGS_DIR, { recursive: true });
const ts = new Date().toISOString();
const dur = formatDuration(durationMs);
const lines = [`=== [${ts}] ${name} [${dur}] → ${status} ===`];
for (const [k, v] of Object.entries(fields)) {
if (v === undefined || v === null || v === '') continue;
lines.push(` ${k.toLowerCase()}: ${String(v)}`);
}
if (rawRel) lines.push(` raw: ${rawRel}`);
lines.push('');
fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n');
}
/** A user answered a prompt. Logs as its own entry because the setup path depends on it. */
export function userInput(key: string, value: string): void {
fs.mkdirSync(LOGS_DIR, { recursive: true });
const ts = new Date().toISOString();
fs.appendFileSync(
PROGRESS_LOG,
`=== [${ts}] user-input → ${key} ===\n value: ${value}\n\n`,
);
}
/** Append the success footer. */
export function complete(totalMs: number): void {
const ts = new Date().toISOString();
fs.appendFileSync(
PROGRESS_LOG,
`## ${ts} · completed (total ${formatDurationTotal(totalMs)})\n`,
);
}
/** Append the failure footer. Keep error short — full context lives in the failing step's raw log. */
export function abort(stepName: string, error: string): void {
const ts = new Date().toISOString();
fs.appendFileSync(
PROGRESS_LOG,
`## ${ts} · aborted at ${stepName} (${error})\n`,
);
}
/**
* Return the next raw-log path for a given step name. Numbering is derived
* from the count of existing NN-*.log files in STEPS_DIR, so bootstrap's
* pre-existing 01-bootstrap.log (written by nanoclaw.sh before this module
* is loaded) counts toward the sequence.
*/
export function stepRawLog(name: string): string {
fs.mkdirSync(STEPS_DIR, { recursive: true });
const existing = fs
.readdirSync(STEPS_DIR)
.filter((n) => /^\d+-.+\.log$/.test(n));
const nextIdx = existing.length + 1;
const num = String(nextIdx).padStart(2, '0');
const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
return path.join(STEPS_DIR, `${num}-${safeName}.log`);
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatDurationTotal(ms: number): string {
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Step: onecli Install + configure the OneCLI gateway and CLI.
*
* Aggregates what the old /setup + /init-onecli skills ran as loose shell
* commands. Idempotent: skips install if `onecli` already works, and safely
* re-applies PATH, api-host, and .env updates.
*
* Emits ONECLI_URL so /new-setup SKILL.md can forward it downstream (e.g. as
* ${ONECLI_URL} in status messages). Polls /health to give downstream steps
* (auth, service) a ready gateway.
*/
import { execFileSync, execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
function childEnv(): NodeJS.ProcessEnv {
const parts = [LOCAL_BIN];
if (process.env.PATH) parts.push(process.env.PATH);
return { ...process.env, PATH: parts.join(path.delimiter) };
}
function onecliVersion(): string | null {
try {
return execFileSync('onecli', ['version'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
}
function extractUrlFromOutput(output: string): string | null {
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
return match ? match[0] : null;
}
function ensureShellProfilePath(): void {
const home = os.homedir();
const line = 'export PATH="$HOME/.local/bin:$PATH"';
for (const profile of [path.join(home, '.bashrc'), path.join(home, '.zshrc')]) {
try {
const content = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : '';
if (!content.includes('.local/bin')) {
fs.appendFileSync(profile, `\n${line}\n`);
log.info('Added ~/.local/bin to PATH in shell profile', { profile });
}
} catch (err) {
log.warn('Could not update shell profile', { profile, err });
}
}
}
function writeEnvOnecliUrl(url: string): void {
const envFile = path.join(process.cwd(), '.env');
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
if (/^ONECLI_URL=/m.test(content)) {
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
} else {
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
}
fs.writeFileSync(envFile, content);
}
function installOnecli(): { stdout: string; ok: boolean } {
// OneCLI's own install script handles gateway + CLI + PATH.
// We run the two canonical installers in sequence and capture stdout so
// we can extract the printed URL as a fallback to `onecli config get`.
let stdout = '';
try {
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, ok: true };
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
log.error('OneCLI install failed', { stderr: e.stderr });
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
}
}
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/api/health`);
if (res.ok) return true;
} catch {
// not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
export async function run(_args: string[]): Promise<void> {
ensureShellProfilePath();
log.info('Installing OneCLI gateway and CLI');
const res = installOnecli();
if (!res.ok) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'install_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
if (!onecliVersion()) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'onecli_not_on_path_after_install',
HINT: 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"` and retry.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
const url = extractUrlFromOutput(res.stdout);
if (!url) {
emitStatus('ONECLI', {
INSTALLED: true,
STATUS: 'failed',
ERROR: 'could_not_resolve_api_host',
HINT: 'Inspect logs/setup.log for the install output.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
try {
execFileSync('onecli', ['config', 'set', 'api-host', url], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli config set api-host failed', { err });
}
writeEnvOnecliUrl(url);
log.info('Wrote ONECLI_URL to .env', { url });
const healthy = await pollHealth(url, 15000);
emitStatus('ONECLI', {
INSTALLED: true,
ONECLI_URL: url,
HEALTHY: healthy,
// Install succeeded regardless — a failed health poll often just means
// the endpoint is auth-gated or the gateway hasn't finished warming up.
// The next step (auth) will surface a genuinely broken gateway via
// `onecli secrets list`, so don't trigger rescue attempts from here.
STATUS: 'success',
...(healthy
? {}
: {
HEALTH_HINT:
'Health poll returned non-ok within 15s — likely auth-gated. Proceed to the auth step; it will surface a real outage.',
}),
LOG: 'logs/setup.log',
});
}
+41 -41
View File
@@ -1,17 +1,23 @@
/**
* Step: pair-telegram issue a one-time pairing code and wait for the
* operator to send `@botname CODE` from the chat they want to register.
* operator to send the code from the chat they want to register.
*
* On success, prints platformId / isGroup / pairedUserId / intent. The caller
* (skill) can then wire the chat to an agent group (e.g. via /init-first-agent
* or setup --step register). telegram.ts's inbound interceptor has already
* upserted the paired user and granted owner if no owner existed yet.
* Emits machine-readable status blocks only. The parent driver
* (`setup:auto`) renders the code / attempt / success UI with clack. Running
* this step directly will look sparse that's intentional.
*
* The service must already be running so the telegram adapter is polling.
* Blocks emitted:
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
* PAIR_TELEGRAM_ATTEMPT { CANDIDATE }
* PAIR_TELEGRAM (final) { STATUS=success, CODE, INTENT, PLATFORM_ID,
* IS_GROUP, PAIRED_USER_ID }
* or { STATUS=failed, CODE, ERROR }
*
* Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh
* copies in from the `channels` branch before this step runs. setup/ is
* excluded from the host tsconfig, so this file's import resolves only at
* runtime tsc won't complain on branches that haven't run add-telegram yet.
*/
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { DATA_DIR } from '../src/config.js';
import path from 'path';
import {
@@ -19,24 +25,25 @@ import {
waitForPairing,
type PairingIntent,
} from '../src/channels/telegram-pairing.js';
import { DATA_DIR } from '../src/config.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): PairingIntent {
let intent: PairingIntent = 'main';
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--intent': {
const raw = args[++i] || 'main';
if (raw === 'main') {
intent = 'main';
} else if (raw.startsWith('wire-to:')) {
intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) };
} else if (raw.startsWith('new-agent:')) {
intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) };
} else {
throw new Error(`Unknown intent: ${raw}`);
}
break;
if (args[i] === '--intent') {
const raw = args[++i] || 'main';
if (raw === 'main') {
intent = 'main';
} else if (raw.startsWith('wire-to:')) {
intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) };
} else if (raw.startsWith('new-agent:')) {
intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) };
} else {
throw new Error(`Unknown intent: ${raw}`);
}
}
}
@@ -51,20 +58,18 @@ function intentToString(intent: PairingIntent): string {
export async function run(args: string[]): Promise<void> {
const intent = parseArgs(args);
// Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly
// required for the pairing primitive itself, but the inbound interceptor
// (running in the live service) needs it. Touch it here so a fresh install
// doesn't blow up on the first match.
// Pairing stores state under DATA_DIR; the DB isn't strictly needed for the
// pairing primitive itself, but the inbound interceptor running inside the
// live service needs migrations applied. Touch it here so a fresh install
// doesn't fail on the first code match.
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
const MAX_REGENERATIONS = 5;
let record = await createPairing(intent);
emitStatus('PAIR_TELEGRAM_ISSUED', {
emitStatus('PAIR_TELEGRAM_CODE', {
CODE: record.code,
INTENT: intentToString(intent),
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${record.code}" in a group with privacy on).`,
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
REASON: 'initial',
});
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
@@ -72,13 +77,11 @@ export async function run(args: string[]): Promise<void> {
const consumed = await waitForPairing(record.code, {
onAttempt: (a) => {
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
EXPECTED_CODE: record.code,
RECEIVED_CODE: a.candidate,
PLATFORM_ID: a.platformId,
AT: a.at,
CANDIDATE: a.candidate,
});
},
});
emitStatus('PAIR_TELEGRAM', {
STATUS: 'success',
CODE: record.code,
@@ -95,20 +98,17 @@ export async function run(args: string[]): Promise<void> {
const invalidated = /invalidated by wrong code/.test(message);
if (invalidated && regen < MAX_REGENERATIONS) {
record = await createPairing(intent);
emitStatus('PAIR_TELEGRAM_NEW_CODE', {
emitStatus('PAIR_TELEGRAM_CODE', {
CODE: record.code,
INTENT: intentToString(intent),
REASON: 'previous code invalidated by wrong attempt',
REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1,
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`,
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
REASON: 'regenerated',
});
continue;
}
const reason = invalidated ? 'max-regenerations-exceeded' : message;
emitStatus('PAIR_TELEGRAM', {
STATUS: 'failed',
CODE: record.code,
ERROR: invalidated ? 'max-regenerations-exceeded' : message,
ERROR: reason,
});
process.exit(2);
}
Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/bash
# Setup step: probe — single upfront parallel-ish scan that snapshots every
# prerequisite and dependency for /new-setup's dynamic context injection.
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
# the current system state before generating its first response.
#
# Pure bash by design: this runs BEFORE setup.sh has installed Node, pnpm, and
# node_modules, so it cannot rely on any Node-based tooling. Every field below
# is computed from POSIX utilities + grep/awk/curl.
#
# This is a routing aid, NOT a replacement for per-step idempotency checks.
# Each step keeps its own checks; probe tells the skill which steps to skip.
#
# Keep fast (<2s total). All probes swallow their own errors and report a
# neutral state rather than failing the whole scan.
set -u
START_S=$(date +%s)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_BIN="$HOME/.local/bin"
AGENT_IMAGE="nanoclaw-agent:latest"
export PATH="$LOCAL_BIN:$PATH"
command_exists() { command -v "$1" >/dev/null 2>&1; }
# Best-effort 2s timeout; falls back to no timeout on macOS if `timeout` isn't
# installed (the probed commands are all expected to return fast anyway).
with_timeout() {
if command_exists timeout; then timeout 2 "$@"
elif command_exists gtimeout; then gtimeout 2 "$@"
else "$@"
fi
}
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
read_env_var() {
local name="$1"
local envfile="$PROJECT_ROOT/.env"
[[ -f "$envfile" ]] || return 0
local line
line=$(grep -E "^${name}=" "$envfile" 2>/dev/null | head -n1) || return 0
[[ -z "$line" ]] && return 0
local val="${line#*=}"
val="${val%\"}"; val="${val#\"}"
val="${val%\'}"; val="${val#\'}"
trim "$val"
}
probe_os() {
case "$(uname -s 2>/dev/null)" in
Darwin) echo "macos" ;;
Linux)
if [[ -r /proc/version ]] && grep -qEi "microsoft|wsl" /proc/version; then
echo "wsl"
else
echo "linux"
fi
;;
*) echo "unknown" ;;
esac
}
probe_host_deps() {
local node_modules="$PROJECT_ROOT/node_modules"
local native="$node_modules/better-sqlite3/build/Release/better_sqlite3.node"
# `better-sqlite3`'s compiled native binding is the canonical proof that
# `pnpm install` ran AND the native build step succeeded.
if [[ -d "$node_modules" && -f "$native" ]]; then
echo "ok"
else
echo "missing"
fi
}
# Sets DOCKER_STATUS and IMAGE_PRESENT as globals.
probe_docker() {
DOCKER_STATUS="not_found"
IMAGE_PRESENT="false"
command_exists docker || return 0
if ! with_timeout docker info >/dev/null 2>&1; then
DOCKER_STATUS="installed_not_running"
return 0
fi
DOCKER_STATUS="running"
if with_timeout docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
IMAGE_PRESENT="true"
fi
}
probe_onecli_url() {
local url
url=$(read_env_var ONECLI_URL)
if [[ -n "$url" ]]; then
printf '%s' "$url"
return
fi
command_exists onecli || return 0
local out
out=$(with_timeout onecli config get api-host 2>/dev/null) || return 0
# Minimal JSON extract: {"value":"http..."} — avoid hard dep on jq
if [[ "$out" =~ \"value\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
printf '%s' "${BASH_REMATCH[1]}"
fi
}
probe_onecli_status() {
local url="$1"
if ! command_exists onecli && [[ ! -x "$LOCAL_BIN/onecli" ]]; then
echo "not_found"; return
fi
if [[ -z "$url" ]]; then
echo "installed_not_healthy"; return
fi
if command_exists curl \
&& curl -fsS --max-time 2 "${url}/api/health" >/dev/null 2>&1; then
echo "healthy"
else
echo "installed_not_healthy"
fi
}
probe_anthropic_secret() {
command_exists onecli || { echo "false"; return; }
local out
out=$(with_timeout onecli secrets list 2>/dev/null) || { echo "false"; return; }
if echo "$out" | grep -Eq '"type"[[:space:]]*:[[:space:]]*"anthropic"'; then
echo "true"
else
echo "false"
fi
}
probe_service_status() {
local platform="$1"
case "$platform" in
macos)
command_exists launchctl || { echo "not_configured"; return; }
local line
line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || {
echo "not_configured"; return; }
local pid
pid=$(echo "$line" | awk '{print $1}')
if [[ -n "$pid" && "$pid" != "-" ]]; then
echo "running"
else
echo "stopped"
fi
;;
linux|wsl)
command_exists systemctl || { echo "not_configured"; return; }
if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then
echo "running"
elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then
echo "stopped"
else
echo "not_configured"
fi
;;
*)
echo "not_configured"
;;
esac
}
probe_display_name() {
local platform="$1"
local reject_re='^(|root)$'
local name
if command_exists git; then
name=$(trim "$(git config --global user.name 2>/dev/null)")
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
printf '%s' "$name"; return
fi
fi
local user="${USER:-$(id -un 2>/dev/null)}"
case "$platform" in
macos)
if command_exists id; then
name=$(trim "$(id -F "$user" 2>/dev/null)")
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
printf '%s' "$name"; return
fi
fi
;;
linux|wsl)
if command_exists getent; then
local entry gecos
entry=$(getent passwd "$user" 2>/dev/null)
gecos=$(echo "$entry" | awk -F: '{print $5}')
name=$(trim "$(echo "$gecos" | awk -F, '{print $1}')")
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
printf '%s' "$name"; return
fi
fi
;;
esac
if [[ -n "$user" && ! "$user" =~ $reject_re ]]; then
printf '%s' "$user"
else
printf 'User'
fi
}
OS=$(probe_os)
SHELL_NAME="${SHELL:-unknown}"
HOST_DEPS=$(probe_host_deps)
probe_docker
ONECLI_URL_VAL=$(probe_onecli_url)
ONECLI_STATUS=$(probe_onecli_status "$ONECLI_URL_VAL")
if [[ "$ONECLI_STATUS" == "not_found" ]]; then
ANTHROPIC_SECRET="false"
else
ANTHROPIC_SECRET=$(probe_anthropic_secret)
fi
SERVICE_STATUS=$(probe_service_status "$OS")
DISPLAY_NAME=$(probe_display_name "$OS")
END_S=$(date +%s)
ELAPSED_MS=$(( (END_S - START_S) * 1000 ))
cat <<EOF
=== NANOCLAW SETUP: PROBE ===
OS: ${OS}
SHELL: ${SHELL_NAME}
HOST_DEPS: ${HOST_DEPS}
DOCKER: ${DOCKER_STATUS}
IMAGE_PRESENT: ${IMAGE_PRESENT}
ONECLI_STATUS: ${ONECLI_STATUS}
ONECLI_URL: ${ONECLI_URL_VAL:-none}
ANTHROPIC_SECRET: ${ANTHROPIC_SECRET}
SERVICE_STATUS: ${SERVICE_STATUS}
INFERRED_DISPLAY_NAME: ${DISPLAY_NAME}
ELAPSED_MS: ${ELAPSED_MS}
STATUS: success
=== END ===
EOF
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail
# Register a Claude subscription OAuth token with OneCLI — the *only* auth
# path that needs a TTY break in the flow. Paste-based paths (existing
# OAuth token / API key) are handled in-process by setup/auto.ts using
# clack prompts, then onecli secrets create is invoked directly from TS.
#
# Flow:
# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser
# OAuth dance works and its token is captured into a tempfile.
# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture.
# 3. Register it with OneCLI.
#
# Env overrides:
# SECRET_NAME OneCLI secret name (default: Anthropic)
# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com)
# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in
# /bin/bash, but Homebrew users usually have 5.x first on PATH. The
# readline preload is optional — on 3.x we fall back to a plain prompt.
SECRET_NAME="${SECRET_NAME:-Anthropic}"
HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}"
command -v onecli >/dev/null \
|| { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; }
if ! command -v claude >/dev/null 2>&1; then
echo "Claude Code CLI not found — installing it now (needed for subscription sign-in)…"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! bash "$SCRIPT_DIR/install-claude.sh"; then
echo >&2
echo "Couldn't install the Claude Code CLI automatically." >&2
echo "Install it manually with" >&2
echo " curl -fsSL https://claude.ai/install.sh | bash" >&2
echo "and re-run setup." >&2
exit 1
fi
# install-claude.sh PATH additions are scoped to its own subshell; redo
# them here so the rest of this script can see the fresh `claude` binary.
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
export PATH="$HOME/.local/bin:$PATH"
fi
hash -r 2>/dev/null || true
fi
command -v script >/dev/null \
|| { echo "script(1) is required for PTY capture." >&2; exit 1; }
tmpfile=$(mktemp -t claude-setup-token.XXXXXX)
trap 'rm -f "$tmpfile"' EXIT
cat <<'EOF'
A browser window will open for you to sign in with your Claude account.
When you finish, we'll save the token to your OneCLI vault automatically.
Press Enter to continue, or edit the command first.
EOF
cmd="claude setup-token"
if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
# bash 4+: pre-fill the readline buffer so Enter literally submits.
read -r -e -i "$cmd" -p "$ " cmd </dev/tty
else
# bash 3.x (macOS default /bin/bash): no readline preload. Fall back.
echo "$ $cmd"
read -r -p "Press Enter to run, Ctrl-C to abort. " _ </dev/tty
fi
# `script` arg order differs between BSD (macOS) and util-linux.
if script --version 2>/dev/null | grep -q util-linux; then
script -q -c "$cmd" "$tmpfile"
else
# BSD script: command is argv after the file, so let it word-split.
# shellcheck disable=SC2086
script -q "$tmpfile" $cmd
fi
# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match
# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255.
token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \
| tr -d '\n\r' \
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
| tail -1 || true)
if [ -z "$token" ]; then
keep=$(mktemp -t claude-setup-token-log.XXXXXX)
cp "$tmpfile" "$keep"
echo >&2
echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2
exit 1
fi
echo
echo "Got token: ${token:0:16}${token: -4}"
echo "Saving it to your OneCLI vault as '${SECRET_NAME}' (host: ${HOST_PATTERN})…"
onecli secrets create \
--name "$SECRET_NAME" \
--type anthropic \
--value "$token" \
--host-pattern "$HOST_PATTERN"
echo "Done."
+25 -2
View File
@@ -11,6 +11,7 @@ import path from 'path';
import { log } from '../src/log.js';
import {
commandExists,
getPlatform,
getNodePath,
getServiceManager,
@@ -255,12 +256,34 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
fs.writeFileSync(unitPath, unit);
log.info('Wrote systemd unit', { unitPath });
// Detect stale docker group before starting (user systemd only)
const dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
// Detect stale docker group before starting (user systemd only). The user
// systemd manager is a long-running process whose group list is frozen at
// login, so `usermod -aG docker` mid-session doesn't reach it. Rather than
// require the user to log out + back in, punch a POSIX ACL onto the socket
// that grants the current user rw directly. This is temporary — the socket
// is recreated by dockerd on restart (and by then the user has relogged, so
// normal group perms apply again).
let dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
if (dockerGroupStale) {
log.warn(
'Docker group not active in systemd session — user was likely added to docker group mid-session',
);
if (commandExists('setfacl')) {
const user = execSync('whoami', { encoding: 'utf-8' }).trim();
try {
execSync(`sudo setfacl -m u:${user}:rw /var/run/docker.sock`, {
stdio: 'inherit',
});
log.info(
'Applied temporary ACL to /var/run/docker.sock (resets on docker restart or reboot)',
);
dockerGroupStale = false;
} catch (err) {
log.warn('Failed to apply setfacl workaround', { err });
}
} else {
log.warn('setfacl not installed — cannot apply automatic workaround');
}
}
// Kill orphaned nanoclaw processes to avoid channel connection conflicts
+77
View File
@@ -0,0 +1,77 @@
/**
* Step: set-env Write or update a KEY=VALUE in .env, with optional sync to
* data/env/env (the container-mounted copy).
*
* Usage:
* pnpm exec tsx setup/index.ts --step set-env -- \
* --key TELEGRAM_BOT_TOKEN --value "<token>" [--sync-container]
*
* Exists so channel-install flows don't have to invent grep/sed/rm pipelines
* (which can't be allowlisted tightly sed can read any file, and each
* segment of an && chain is matched separately).
*
* Logs the key but never the value.
*/
import fs from 'fs';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
export async function run(args: string[]): Promise<void> {
const keyIdx = args.indexOf('--key');
const valueIdx = args.indexOf('--value');
const syncContainer = args.includes('--sync-container');
if (keyIdx === -1 || !args[keyIdx + 1]) {
throw new Error('--key <KEY> is required');
}
if (valueIdx === -1 || args[valueIdx + 1] === undefined) {
throw new Error('--value <VALUE> is required');
}
const key = args[keyIdx + 1];
const value = args[valueIdx + 1];
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
}
const projectRoot = process.cwd();
const envFile = path.join(projectRoot, '.env');
let content = '';
if (fs.existsSync(envFile)) {
content = fs.readFileSync(envFile, 'utf-8');
}
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
const newLine = `${key}=${value}`;
const existed = lineRegex.test(content);
if (existed) {
content = content.replace(lineRegex, newLine);
} else {
const sep = content && !content.endsWith('\n') ? '\n' : '';
content = content + sep + newLine + '\n';
}
fs.writeFileSync(envFile, content);
log.info('Updated .env', { key, existed });
let synced = false;
if (syncContainer) {
const dataEnvDir = path.join(projectRoot, 'data', 'env');
fs.mkdirSync(dataEnvDir, { recursive: true });
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
synced = true;
log.info('Synced .env to container mount', { path: 'data/env/env' });
}
emitStatus('SET_ENV', {
KEY: key,
EXISTED: existed,
SYNCED_TO_CONTAINER: synced,
STATUS: 'success',
});
}
+89 -18
View File
@@ -14,6 +14,7 @@ import Database from 'better-sqlite3';
import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js';
import { log } from '../src/log.js';
import { pingCliAgent } from './lib/agent-ping.js';
import {
getPlatform,
getServiceManager,
@@ -29,19 +30,35 @@ export async function run(_args: string[]): Promise<void> {
log.info('Starting verification');
// 1. Check service status
let service = 'not_found';
// 1. Check service status + detect checkout mismatch.
//
// Why the mismatch matters: the host binds `<DATA_DIR>/cli.sock` relative
// to the project root it was started from. If the running service is from
// a sibling checkout (common for developers with multiple clones), this
// repo's `data/cli.sock` won't exist — AGENT_PING would return a
// misleading `socket_error`. Surface the mismatch directly instead.
let service:
| 'not_found'
| 'stopped'
| 'running'
| 'running_other_checkout' = 'not_found';
let runningFromPath: string | null = null;
const mgr = getServiceManager();
if (mgr === 'launchd') {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
if (output.includes('com.nanoclaw')) {
// Check if it has a PID (actually running)
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
service = pidField !== '-' && pidField ? 'running' : 'stopped';
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
if (pidField !== '-' && pidField) {
service = 'running';
const pid = Number(pidField);
if (Number.isInteger(pid) && pid > 0) {
runningFromPath = resolveBinaryScript(pid);
}
} else {
service = 'stopped';
}
}
} catch {
@@ -52,6 +69,18 @@ export async function run(_args: string[]): Promise<void> {
try {
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
service = 'running';
try {
const pidStr = execSync(
`${prefix} show nanoclaw -p MainPID --value`,
{ encoding: 'utf-8' },
).trim();
const pid = Number(pidStr);
if (Number.isInteger(pid) && pid > 0) {
runningFromPath = resolveBinaryScript(pid);
}
} catch {
// couldn't read MainPID; leave runningFromPath null
}
} catch {
try {
const output = execSync(`${prefix} list-unit-files`, {
@@ -74,26 +103,31 @@ export async function run(_args: string[]): Promise<void> {
if (raw && Number.isInteger(pid) && pid > 0) {
process.kill(pid, 0);
service = 'running';
runningFromPath = resolveBinaryScript(pid);
}
} catch {
service = 'stopped';
}
}
}
log.info('Service status', { service });
if (
service === 'running' &&
runningFromPath &&
!isPathInside(runningFromPath, projectRoot)
) {
service = 'running_other_checkout';
}
log.info('Service status', { service, runningFromPath });
// 2. Check container runtime
let containerRuntime = 'none';
try {
execSync('command -v container', { stdio: 'ignore' });
containerRuntime = 'apple-container';
execSync('docker info', { stdio: 'ignore' });
containerRuntime = 'docker';
} catch {
try {
execSync('docker info', { stdio: 'ignore' });
containerRuntime = 'docker';
} catch {
// No runtime
}
// Docker not running
}
// 3. Check credentials
@@ -180,12 +214,22 @@ export async function run(_args: string[]): Promise<void> {
mountAllowlist = 'configured';
}
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
// everything upstream looks healthy, since a broken socket would just hang.
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
if (service === 'running' && registeredGroups > 0) {
log.info('Pinging CLI agent');
agentPing = await pingCliAgent();
log.info('Agent ping result', { agentPing });
}
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
anyChannelConfigured &&
registeredGroups > 0
registeredGroups > 0 &&
(agentPing === 'ok' || agentPing === 'skipped')
? 'success'
: 'failed';
@@ -199,9 +243,36 @@ export async function run(_args: string[]): Promise<void> {
CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
AGENT_PING: agentPing,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}
/**
* Given a PID, resolve the script path the process is executing (i.e. the
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
* error callers should treat null as "couldn't tell" and skip the
* mismatch check rather than flag a false positive.
*/
function resolveBinaryScript(pid: number): string | null {
try {
// BSD ps (macOS) and util-linux both honour `-o command=` (full argv,
// no header). Node argv: "node /path/to/dist/index.js ...".
const out = execSync(`ps -p ${pid} -o command=`, {
encoding: 'utf-8',
}).trim();
const tokens = out.split(/\s+/);
const script = tokens.find((t) => /\.(js|mjs|cjs|ts)$/.test(t));
return script ?? null;
} catch {
return null;
}
}
function isPathInside(candidate: string, parent: string): boolean {
const rel = path.relative(parent, candidate);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}