From 9c9d191d6fc3e6e5e33612ca877ad8be7d93eead Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 05:32:49 +0100 Subject: [PATCH] feat: improve cli setup flow --- docs/start/wizard.md | 1 + src/auto-reply/status.ts | 71 +------------------------------------ src/cli/banner.ts | 48 +++++++++++++++++++++++++ src/cli/program.test.ts | 20 +++++++++++ src/cli/program.ts | 30 ++++++++-------- src/cli/tagline.ts | 45 ++++++++++++++++++++++++ src/infra/git-commit.ts | 76 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 206 insertions(+), 85 deletions(-) create mode 100644 src/cli/banner.ts create mode 100644 src/cli/tagline.ts create mode 100644 src/infra/git-commit.ts diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 0a558e9f6..b3b68120a 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -91,6 +91,7 @@ clawdbot agents add 7) **Health check** - Starts the Gateway (if needed) and runs `clawdbot health`. + - Tip: `clawdbot status --deep` runs local provider probes without a gateway. 8) **Skills (recommended)** - Reads the available skills and checks requirements. diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index db0849455..baa0b233c 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import path from "node:path"; import { lookupContextTokens } from "../agents/context.js"; import { @@ -20,6 +19,7 @@ import { type SessionEntry, type SessionScope, } from "../config/sessions.js"; +import { resolveCommitHash } from "../infra/git-commit.js"; import { VERSION } from "../version.js"; import type { ElevatedLevel, @@ -92,75 +92,6 @@ export const formatContextUsageShort = ( contextTokens: number | null | undefined, ) => `Context ${formatTokens(total, contextTokens ?? null)}`; -const formatCommit = (value?: string | null) => { - if (!value) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed; -}; - -const resolveGitHead = (startDir: string) => { - let current = startDir; - for (let i = 0; i < 12; i += 1) { - const gitPath = path.join(current, ".git"); - try { - const stat = fs.statSync(gitPath); - if (stat.isDirectory()) { - return path.join(gitPath, "HEAD"); - } - if (stat.isFile()) { - const raw = fs.readFileSync(gitPath, "utf-8"); - const match = raw.match(/gitdir:\s*(.+)/i); - if (match?.[1]) { - const resolved = path.resolve(current, match[1].trim()); - return path.join(resolved, "HEAD"); - } - } - } catch { - // ignore missing .git at this level - } - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - return null; -}; - -let cachedCommit: string | null | undefined; -const resolveCommitHash = () => { - if (cachedCommit !== undefined) return cachedCommit; - const envCommit = - process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim(); - const normalized = formatCommit(envCommit); - if (normalized) { - cachedCommit = normalized; - return cachedCommit; - } - try { - const headPath = resolveGitHead(process.cwd()); - if (!headPath) { - cachedCommit = null; - return cachedCommit; - } - const head = fs.readFileSync(headPath, "utf-8").trim(); - if (!head) { - cachedCommit = null; - return cachedCommit; - } - if (head.startsWith("ref:")) { - const ref = head.replace(/^ref:\s*/i, "").trim(); - const refPath = path.resolve(path.dirname(headPath), ref); - const refHash = fs.readFileSync(refPath, "utf-8").trim(); - cachedCommit = formatCommit(refHash); - return cachedCommit; - } - cachedCommit = formatCommit(head); - return cachedCommit; - } catch { - cachedCommit = null; - return cachedCommit; - } -}; const formatQueueDetails = (queue?: QueueStatus) => { if (!queue) return ""; diff --git a/src/cli/banner.ts b/src/cli/banner.ts new file mode 100644 index 000000000..bfd882891 --- /dev/null +++ b/src/cli/banner.ts @@ -0,0 +1,48 @@ +import { resolveCommitHash } from "../infra/git-commit.js"; +import { isRich, theme } from "../terminal/theme.js"; +import { pickTagline, type TaglineOptions } from "./tagline.js"; + +type BannerOptions = TaglineOptions & { + argv?: string[]; + commit?: string | null; + richTty?: boolean; +}; + +let bannerEmitted = false; + +const hasJsonFlag = (argv: string[]) => + argv.some((arg) => arg === "--json" || arg.startsWith("--json=")); + +const hasVersionFlag = (argv: string[]) => + argv.some((arg) => arg === "--version" || arg === "-V" || arg === "-v"); + +export function formatCliBannerLine( + version: string, + options: BannerOptions = {}, +): string { + const commit = options.commit ?? resolveCommitHash({ env: options.env }); + const commitLabel = commit ?? "unknown"; + const tagline = pickTagline(options); + const rich = options.richTty ?? isRich(); + const title = "🦞 ClawdBot"; + if (rich) { + return `${theme.heading(title)} ${theme.info(version)} ${theme.muted( + `(${commitLabel})`, + )} ${theme.muted("—")} ${theme.accentDim(tagline)}`; + } + return `${title} ${version} (${commitLabel}) — ${tagline}`; +} + +export function emitCliBanner( + version: string, + options: BannerOptions = {}, +) { + if (bannerEmitted) return; + const argv = options.argv ?? process.argv; + if (!process.stdout.isTTY) return; + if (hasJsonFlag(argv)) return; + if (hasVersionFlag(argv)) return; + const line = formatCliBannerLine(version, options); + process.stdout.write(`\n${line}\n\n`); + bannerEmitted = true; +} diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index 3b6dbdf64..79109504c 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const sendCommand = vi.fn(); const statusCommand = vi.fn(); const configureCommand = vi.fn(); +const setupCommand = vi.fn(); +const onboardCommand = vi.fn(); const loginWeb = vi.fn(); const callGateway = vi.fn(); @@ -18,6 +20,8 @@ const runtime = { vi.mock("../commands/send.js", () => ({ sendCommand })); vi.mock("../commands/status.js", () => ({ statusCommand })); vi.mock("../commands/configure.js", () => ({ configureCommand })); +vi.mock("../commands/setup.js", () => ({ setupCommand })); +vi.mock("../commands/onboard.js", () => ({ onboardCommand })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("../provider-web.js", () => ({ loginWeb, @@ -57,6 +61,22 @@ describe("cli program", () => { expect(configureCommand).toHaveBeenCalled(); }); + it("runs setup without wizard flags", async () => { + const program = buildProgram(); + await program.parseAsync(["setup"], { from: "user" }); + expect(setupCommand).toHaveBeenCalled(); + expect(onboardCommand).not.toHaveBeenCalled(); + }); + + it("runs setup wizard when wizard flags are present", async () => { + const program = buildProgram(); + await program.parseAsync(["setup", "--remote-url", "ws://example"], { + from: "user", + }); + expect(onboardCommand).toHaveBeenCalled(); + expect(setupCommand).not.toHaveBeenCalled(); + }); + it("runs nodes list and calls node.pair.list", async () => { callGateway.mockResolvedValue({ pending: [], paired: [] }); const program = buildProgram(); diff --git a/src/cli/program.ts b/src/cli/program.ts index 313bf78fe..5c63775b3 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -45,6 +45,7 @@ import { forceFreePort } from "./ports.js"; import { registerProvidersCli } from "./providers-cli.js"; import { registerTelegramCli } from "./telegram-cli.js"; import { registerTuiCli } from "./tui-cli.js"; +import { emitCliBanner, formatCliBannerLine } from "./banner.js"; import { isRich, theme } from "../terminal/theme.js"; export { forceFreePort }; @@ -52,8 +53,6 @@ export { forceFreePort }; export function buildProgram() { const program = new Command(); const PROGRAM_VERSION = VERSION; - const TAGLINE = - "Send, receive, and auto-reply on WhatsApp (web) and Telegram (bot)."; program .name("clawdbot") @@ -68,13 +67,6 @@ export function buildProgram() { "Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-)", ); - const formatIntroLine = (version: string, rich = true) => { - const base = `🦞 ClawdBot ${version} — ${TAGLINE}`; - return rich - ? `${theme.heading("🦞 ClawdBot")} ${theme.info(version)} ${theme.muted("—")} ${theme.accentDim(TAGLINE)}` - : base; - }; - program.configureHelp({ optionTerm: (option) => theme.option(option.flags), subcommandTerm: (cmd) => theme.command(cmd.name()), @@ -101,12 +93,13 @@ export function buildProgram() { process.exit(0); } - program.addHelpText( - "beforeAll", - `\n${formatIntroLine(PROGRAM_VERSION, isRich())}\n`, - ); + program.addHelpText("beforeAll", () => { + const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: isRich() }); + return `\n${line}\n`; + }); program.hook("preAction", async (_thisCommand, actionCommand) => { + emitCliBanner(PROGRAM_VERSION); if (actionCommand.name() === "doctor") return; const snapshot = await readConfigFileSnapshot(); if (snapshot.legacyIssues.length === 0) return; @@ -195,9 +188,16 @@ export function buildProgram() { .option("--mode ", "Wizard mode: local|remote") .option("--remote-url ", "Remote Gateway WebSocket URL") .option("--remote-token ", "Remote Gateway token (optional)") - .action(async (opts) => { + .action(async (opts, command) => { try { - if (opts.wizard) { + const hasWizardFlags = hasExplicitOptions(command, [ + "wizard", + "nonInteractive", + "mode", + "remoteUrl", + "remoteToken", + ]); + if (opts.wizard || hasWizardFlags) { await onboardCommand( { workspace: opts.workspace as string | undefined, diff --git a/src/cli/tagline.ts b/src/cli/tagline.ts new file mode 100644 index 000000000..3b5530d34 --- /dev/null +++ b/src/cli/tagline.ts @@ -0,0 +1,45 @@ +const DEFAULT_TAGLINE = + "Send, receive, and auto-reply on WhatsApp (web) and Telegram (bot)."; + +const TAGLINES: string[] = []; + +type HolidayRule = (date: Date) => boolean; + +const HOLIDAY_RULES = new Map(); + +function isTaglineActive(tagline: string, date: Date): boolean { + const rule = HOLIDAY_RULES.get(tagline); + if (!rule) return true; + return rule(date); +} + +export interface TaglineOptions { + env?: NodeJS.ProcessEnv; + random?: () => number; + now?: () => Date; +} + +export function activeTaglines(options: TaglineOptions = {}): string[] { + if (TAGLINES.length === 0) return [DEFAULT_TAGLINE]; + const today = options.now ? options.now() : new Date(); + const filtered = TAGLINES.filter((tagline) => isTaglineActive(tagline, today)); + return filtered.length > 0 ? filtered : TAGLINES; +} + +export function pickTagline(options: TaglineOptions = {}): string { + const env = options.env ?? process.env; + const override = env?.CLAWDBOT_TAGLINE_INDEX; + if (override !== undefined) { + const parsed = Number.parseInt(override, 10); + if (!Number.isNaN(parsed) && parsed >= 0) { + const pool = TAGLINES.length > 0 ? TAGLINES : [DEFAULT_TAGLINE]; + return pool[parsed % pool.length]; + } + } + const pool = activeTaglines(options); + const rand = options.random ?? Math.random; + const index = Math.floor(rand() * pool.length) % pool.length; + return pool[index]; +} + +export { TAGLINES, HOLIDAY_RULES, DEFAULT_TAGLINE }; diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts new file mode 100644 index 000000000..34c684323 --- /dev/null +++ b/src/infra/git-commit.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import path from "node:path"; + +const formatCommit = (value?: string | null) => { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed; +}; + +const resolveGitHead = (startDir: string) => { + let current = startDir; + for (let i = 0; i < 12; i += 1) { + const gitPath = path.join(current, ".git"); + try { + const stat = fs.statSync(gitPath); + if (stat.isDirectory()) { + return path.join(gitPath, "HEAD"); + } + if (stat.isFile()) { + const raw = fs.readFileSync(gitPath, "utf-8"); + const match = raw.match(/gitdir:\s*(.+)/i); + if (match?.[1]) { + const resolved = path.resolve(current, match[1].trim()); + return path.join(resolved, "HEAD"); + } + } + } catch { + // ignore missing .git at this level + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return null; +}; + +let cachedCommit: string | null | undefined; + +export const resolveCommitHash = (options: { + cwd?: string; + env?: NodeJS.ProcessEnv; +} = {}) => { + if (cachedCommit !== undefined) return cachedCommit; + const env = options.env ?? process.env; + const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); + const normalized = formatCommit(envCommit); + if (normalized) { + cachedCommit = normalized; + return cachedCommit; + } + try { + const headPath = resolveGitHead(options.cwd ?? process.cwd()); + if (!headPath) { + cachedCommit = null; + return cachedCommit; + } + const head = fs.readFileSync(headPath, "utf-8").trim(); + if (!head) { + cachedCommit = null; + return cachedCommit; + } + if (head.startsWith("ref:")) { + const ref = head.replace(/^ref:\s*/i, "").trim(); + const refPath = path.resolve(path.dirname(headPath), ref); + const refHash = fs.readFileSync(refPath, "utf-8").trim(); + cachedCommit = formatCommit(refHash); + return cachedCommit; + } + cachedCommit = formatCommit(head); + return cachedCommit; + } catch { + cachedCommit = null; + return cachedCommit; + } +};