feat: improve cli setup flow
This commit is contained in:
@@ -91,6 +91,7 @@ clawdbot agents add <name>
|
|||||||
|
|
||||||
7) **Health check**
|
7) **Health check**
|
||||||
- Starts the Gateway (if needed) and runs `clawdbot health`.
|
- Starts the Gateway (if needed) and runs `clawdbot health`.
|
||||||
|
- Tip: `clawdbot status --deep` runs local provider probes without a gateway.
|
||||||
|
|
||||||
8) **Skills (recommended)**
|
8) **Skills (recommended)**
|
||||||
- Reads the available skills and checks requirements.
|
- Reads the available skills and checks requirements.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionScope,
|
type SessionScope,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import type {
|
import type {
|
||||||
ElevatedLevel,
|
ElevatedLevel,
|
||||||
@@ -92,75 +92,6 @@ export const formatContextUsageShort = (
|
|||||||
contextTokens: number | null | undefined,
|
contextTokens: number | null | undefined,
|
||||||
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
|
) => `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) => {
|
const formatQueueDetails = (queue?: QueueStatus) => {
|
||||||
if (!queue) return "";
|
if (!queue) return "";
|
||||||
|
|||||||
48
src/cli/banner.ts
Normal file
48
src/cli/banner.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
const sendCommand = vi.fn();
|
const sendCommand = vi.fn();
|
||||||
const statusCommand = vi.fn();
|
const statusCommand = vi.fn();
|
||||||
const configureCommand = vi.fn();
|
const configureCommand = vi.fn();
|
||||||
|
const setupCommand = vi.fn();
|
||||||
|
const onboardCommand = vi.fn();
|
||||||
const loginWeb = vi.fn();
|
const loginWeb = vi.fn();
|
||||||
const callGateway = vi.fn();
|
const callGateway = vi.fn();
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ const runtime = {
|
|||||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||||
vi.mock("../commands/configure.js", () => ({ configureCommand }));
|
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("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
loginWeb,
|
loginWeb,
|
||||||
@@ -57,6 +61,22 @@ describe("cli program", () => {
|
|||||||
expect(configureCommand).toHaveBeenCalled();
|
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 () => {
|
it("runs nodes list and calls node.pair.list", async () => {
|
||||||
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { forceFreePort } from "./ports.js";
|
|||||||
import { registerProvidersCli } from "./providers-cli.js";
|
import { registerProvidersCli } from "./providers-cli.js";
|
||||||
import { registerTelegramCli } from "./telegram-cli.js";
|
import { registerTelegramCli } from "./telegram-cli.js";
|
||||||
import { registerTuiCli } from "./tui-cli.js";
|
import { registerTuiCli } from "./tui-cli.js";
|
||||||
|
import { emitCliBanner, formatCliBannerLine } from "./banner.js";
|
||||||
import { isRich, theme } from "../terminal/theme.js";
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
export { forceFreePort };
|
export { forceFreePort };
|
||||||
@@ -52,8 +53,6 @@ export { forceFreePort };
|
|||||||
export function buildProgram() {
|
export function buildProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const PROGRAM_VERSION = VERSION;
|
const PROGRAM_VERSION = VERSION;
|
||||||
const TAGLINE =
|
|
||||||
"Send, receive, and auto-reply on WhatsApp (web) and Telegram (bot).";
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("clawdbot")
|
.name("clawdbot")
|
||||||
@@ -68,13 +67,6 @@ export function buildProgram() {
|
|||||||
"Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-<name>)",
|
"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({
|
program.configureHelp({
|
||||||
optionTerm: (option) => theme.option(option.flags),
|
optionTerm: (option) => theme.option(option.flags),
|
||||||
subcommandTerm: (cmd) => theme.command(cmd.name()),
|
subcommandTerm: (cmd) => theme.command(cmd.name()),
|
||||||
@@ -101,12 +93,13 @@ export function buildProgram() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
program.addHelpText(
|
program.addHelpText("beforeAll", () => {
|
||||||
"beforeAll",
|
const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: isRich() });
|
||||||
`\n${formatIntroLine(PROGRAM_VERSION, isRich())}\n`,
|
return `\n${line}\n`;
|
||||||
);
|
});
|
||||||
|
|
||||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
|
emitCliBanner(PROGRAM_VERSION);
|
||||||
if (actionCommand.name() === "doctor") return;
|
if (actionCommand.name() === "doctor") return;
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
if (snapshot.legacyIssues.length === 0) return;
|
if (snapshot.legacyIssues.length === 0) return;
|
||||||
@@ -195,9 +188,16 @@ export function buildProgram() {
|
|||||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||||
.option("--remote-token <token>", "Remote Gateway token (optional)")
|
.option("--remote-token <token>", "Remote Gateway token (optional)")
|
||||||
.action(async (opts) => {
|
.action(async (opts, command) => {
|
||||||
try {
|
try {
|
||||||
if (opts.wizard) {
|
const hasWizardFlags = hasExplicitOptions(command, [
|
||||||
|
"wizard",
|
||||||
|
"nonInteractive",
|
||||||
|
"mode",
|
||||||
|
"remoteUrl",
|
||||||
|
"remoteToken",
|
||||||
|
]);
|
||||||
|
if (opts.wizard || hasWizardFlags) {
|
||||||
await onboardCommand(
|
await onboardCommand(
|
||||||
{
|
{
|
||||||
workspace: opts.workspace as string | undefined,
|
workspace: opts.workspace as string | undefined,
|
||||||
|
|||||||
45
src/cli/tagline.ts
Normal file
45
src/cli/tagline.ts
Normal 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 };
|
||||||
76
src/infra/git-commit.ts
Normal file
76
src/infra/git-commit.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user