feat: improve cli setup flow

This commit is contained in:
Peter Steinberger
2026-01-08 05:32:49 +01:00
parent 6a684fdf6c
commit 9c9d191d6f
7 changed files with 206 additions and 85 deletions

48
src/cli/banner.ts Normal file
View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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-<name>)",
);
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 <mode>", "Wizard mode: local|remote")
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
.option("--remote-token <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,

45
src/cli/tagline.ts Normal file
View File

@@ -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<string, HolidayRule>();
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 };