mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 18:31:43 +00:00
feat(cli): animate the claw banner and show it on gateway startup (#105540)
* feat(cli): animate the claw banner and show it on foreground gateway startup * fix(cli): restore the terminal when the banner animation is interrupted
This commit is contained in:
committed by
GitHub
parent
c0f0107a5c
commit
24a4f0bbbf
87
src/cli/claw-banner.test.ts
Normal file
87
src/cli/claw-banner.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Claw banner tests: static/animated gating and the final-frame invariant.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { printClawBanner, testing } from "./claw-banner.js";
|
||||
|
||||
const runtimeStub = () => {
|
||||
const log = vi.fn();
|
||||
return { runtime: { log } as unknown as RuntimeEnv, log };
|
||||
};
|
||||
|
||||
async function runAnimated(rng: () => number) {
|
||||
const chunks: string[] = [];
|
||||
const { runtime } = runtimeStub();
|
||||
await printClawBanner(runtime, {
|
||||
columns: 120,
|
||||
isTty: true,
|
||||
rich: true,
|
||||
env: {},
|
||||
rng,
|
||||
sleep: async () => {},
|
||||
write: (chunk) => chunks.push(chunk),
|
||||
});
|
||||
return chunks;
|
||||
}
|
||||
|
||||
describe("printClawBanner", () => {
|
||||
it("prints the static banner when not animatable", async () => {
|
||||
const { runtime, log } = runtimeStub();
|
||||
await printClawBanner(runtime, { columns: 120, isTty: false, env: {} });
|
||||
const output = stripAnsi(String(log.mock.calls[0]?.[0]));
|
||||
expect(output.split("\n")[0]).toBe("▄███▄ ▄███▄");
|
||||
expect(output).toContain("█▀▀▀█ █▀▀▀█ █▀▀▀▀ █▄ █");
|
||||
});
|
||||
|
||||
it("stays static under CI even on a rich TTY", async () => {
|
||||
const { runtime, log } = runtimeStub();
|
||||
await printClawBanner(runtime, { columns: 120, isTty: true, rich: true, env: { CI: "1" } });
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the plain title on narrow terminals", async () => {
|
||||
const { runtime, log } = runtimeStub();
|
||||
await printClawBanner(runtime, { columns: 50, isTty: true, rich: true, env: {} });
|
||||
const output = String(log.mock.calls[0]?.[0]);
|
||||
expect(output).toContain("OPENCLAW");
|
||||
expect(output).not.toContain("█");
|
||||
});
|
||||
|
||||
it("animates on a rich TTY and settles on the exact static banner", async () => {
|
||||
const chunks = await runAnimated(() => 0);
|
||||
expect(chunks[0]).toBe("\x1b[?25l");
|
||||
expect(chunks).toContain("\x1b[?25h");
|
||||
const frames = chunks.filter((chunk) => chunk.includes("\x1b[K"));
|
||||
expect(frames.length).toBeGreaterThan(10);
|
||||
const finalRows = stripAnsi(frames[frames.length - 1] ?? "")
|
||||
.split("\n")
|
||||
.filter((row) => row.length > 0);
|
||||
expect(finalRows).toEqual(testing.staticBannerLines().map((line) => stripAnsi(line)));
|
||||
});
|
||||
|
||||
it("installs scoped signal handlers only while animating", async () => {
|
||||
const before = process.listenerCount("SIGINT");
|
||||
let during = -1;
|
||||
const { runtime } = runtimeStub();
|
||||
await printClawBanner(runtime, {
|
||||
columns: 120,
|
||||
isTty: true,
|
||||
rich: true,
|
||||
env: {},
|
||||
rng: () => 0.99,
|
||||
sleep: async () => {
|
||||
during = Math.max(during, process.listenerCount("SIGINT"));
|
||||
},
|
||||
write: () => {},
|
||||
});
|
||||
expect(during).toBe(before + 1);
|
||||
expect(process.listenerCount("SIGINT")).toBe(before);
|
||||
});
|
||||
|
||||
it("varies snips and shimmer passes with the rng", async () => {
|
||||
// rng below the thresholds adds a second shimmer pass and a second snip.
|
||||
const maximal = (await runAnimated(() => 0)).filter((c) => c.includes("\x1b[K"));
|
||||
const minimal = (await runAnimated(() => 0.99)).filter((c) => c.includes("\x1b[K"));
|
||||
expect(maximal.length).toBeGreaterThan(minimal.length);
|
||||
});
|
||||
});
|
||||
204
src/cli/claw-banner.ts
Normal file
204
src/cli/claw-banner.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// Shared OpenClaw banner: the pixel lobster mascot beside the OPENCLAW
|
||||
// wordmark, with a short startup animation on rich interactive terminals.
|
||||
// Used by the wizard flows (doctor/onboard/configure) and the foreground
|
||||
// gateway run; non-TTY and CI paths always get the plain static banner.
|
||||
import {
|
||||
decorativeEmoji,
|
||||
supportsDecorativeEmoji,
|
||||
} from "../../packages/terminal-core/src/decorative-emoji.js";
|
||||
import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js";
|
||||
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
// Art is pregenerated from pixel bitmaps (two pixel rows per terminal row via
|
||||
// ▀▄█). Mascot and wordmark are separate so they can be tinted independently;
|
||||
// the wordmark starts on mascot row 2, keeping the claws above the text line.
|
||||
const MASCOT_ART = [
|
||||
"▄███▄ ▄███▄",
|
||||
"▀█▄█▀ ▀█▄█▀",
|
||||
" ▀▄ ▄▀",
|
||||
" ██ █ ██",
|
||||
" ▀█████▀",
|
||||
" ▄█▀ █ ▀█▄",
|
||||
] as const;
|
||||
// Claw tips with the pincer notch widened; swapping the top two rows in and
|
||||
// out produces the "snip".
|
||||
const MASCOT_OPEN_ROWS = ["▄█▀█▄ ▄█▀█▄", "▀█ █▀ ▀█ █▀"] as const;
|
||||
const MASCOT_WIDTH = 15;
|
||||
const WORDMARK_ROW_OFFSET = 2;
|
||||
|
||||
const WORDMARK_ART = [
|
||||
"█▀▀▀█ █▀▀▀█ █▀▀▀▀ █▄ █ █▀▀▀▀ █ █▀▀▀█ █ █",
|
||||
"█ █ █▀▀▀▀ █▀▀▀ █ ▀▄█ █ █ █▀▀▀█ █▄▀▄█",
|
||||
"▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀",
|
||||
] as const;
|
||||
const GAP = 3;
|
||||
const BANNER_WIDTH = MASCOT_WIDTH + GAP + 48;
|
||||
const ROWS = MASCOT_ART.length;
|
||||
|
||||
export type ClawBannerOptions = {
|
||||
columns?: number;
|
||||
isTty?: boolean;
|
||||
rich?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Injectable randomness for the animation garnish (tests pin it). */
|
||||
rng?: () => number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
write?: (chunk: string) => void;
|
||||
};
|
||||
|
||||
type CellTint = (col: number) => (text: string) => string;
|
||||
|
||||
const identityTint: (text: string) => string = (text) => text;
|
||||
|
||||
// Composes one banner frame. Tints run per glyph column so the wipe edge and
|
||||
// shimmer band can cut through individual letters.
|
||||
function composeFrame(params: {
|
||||
mascotRows?: readonly string[];
|
||||
mascotTint?: CellTint;
|
||||
wordmarkTint?: CellTint;
|
||||
}): string[] {
|
||||
const mascotRows = params.mascotRows ?? MASCOT_ART;
|
||||
const lines: string[] = [];
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
const mascotRow = (mascotRows[row] ?? "").padEnd(MASCOT_WIDTH).slice(0, MASCOT_WIDTH);
|
||||
let out = "";
|
||||
for (let col = 0; col < mascotRow.length; col++) {
|
||||
const ch = mascotRow[col] ?? " ";
|
||||
out += ch === " " ? " " : (params.mascotTint?.(col) ?? theme.accent)(ch);
|
||||
}
|
||||
const wordmarkRow = WORDMARK_ART[row - WORDMARK_ROW_OFFSET];
|
||||
if (wordmarkRow) {
|
||||
out += " ".repeat(GAP);
|
||||
for (let col = 0; col < wordmarkRow.length; col++) {
|
||||
const ch = wordmarkRow[col] ?? " ";
|
||||
out +=
|
||||
ch === " " ? " " : (params.wordmarkTint?.(MASCOT_WIDTH + GAP + col) ?? identityTint)(ch);
|
||||
}
|
||||
}
|
||||
lines.push(out.replace(/\s+$/, ""));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function staticBannerLines(): string[] {
|
||||
return composeFrame({});
|
||||
}
|
||||
|
||||
function plainTitleLine(): string {
|
||||
const icon = decorativeEmoji("🦞");
|
||||
return supportsDecorativeEmoji() && icon ? `${icon} OPENCLAW ${icon}` : "OPENCLAW";
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
// One combined entrance: a left-to-right molt wipe reveals the color, a
|
||||
// shimmer band sweeps the wordmark, and the claws snip. The rng varies the
|
||||
// shimmer passes and snip count a little so back-to-back runs don't feel
|
||||
// canned; every sequence ends on the exact static banner.
|
||||
async function animateBanner(opts: {
|
||||
rng: () => number;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
write: (chunk: string) => void;
|
||||
}): Promise<void> {
|
||||
const { rng, sleep, write } = opts;
|
||||
let drewFrame = false;
|
||||
const draw = (lines: string[]) => {
|
||||
const prefix = drewFrame ? `\x1b[${ROWS}F` : "";
|
||||
drewFrame = true;
|
||||
write(`${prefix}${lines.map((line) => `\x1b[K${line}`).join("\n")}\n`);
|
||||
};
|
||||
// Ctrl-C during the ~1s sequence would otherwise kill the process with the
|
||||
// cursor still hidden: default signal death skips the finally block. The
|
||||
// banner runs before any other component installs signal handlers, so a
|
||||
// scoped restore-and-exit handler is safe here and removed right after.
|
||||
const onSignal = (signal: "SIGINT" | "SIGTERM") => {
|
||||
restoreTerminalState(`claw banner ${signal}`);
|
||||
process.exit(signal === "SIGINT" ? 130 : 143);
|
||||
};
|
||||
const onSigint = () => onSignal("SIGINT");
|
||||
const onSigterm = () => onSignal("SIGTERM");
|
||||
process.once("SIGINT", onSigint);
|
||||
process.once("SIGTERM", onSigterm);
|
||||
write("\x1b[?25l");
|
||||
try {
|
||||
// Molt wipe: dim shell ahead of a bright 2-column edge, color behind it.
|
||||
const wipeSteps = 9;
|
||||
for (let step = 0; step <= wipeSteps; step++) {
|
||||
const edge = Math.round((BANNER_WIDTH * step) / wipeSteps);
|
||||
const tintAt =
|
||||
(colored: (text: string) => string): CellTint =>
|
||||
(col) =>
|
||||
col < edge ? colored : col < edge + 2 ? theme.accentBright : theme.muted;
|
||||
draw(
|
||||
composeFrame({
|
||||
mascotTint: tintAt(theme.accent),
|
||||
wordmarkTint: tintAt(identityTint),
|
||||
}),
|
||||
);
|
||||
await sleep(45);
|
||||
}
|
||||
// Shimmer: a bright band sweeps the wordmark; rarely it runs twice.
|
||||
const shimmerPasses = rng() < 0.2 ? 2 : 1;
|
||||
for (let pass = 0; pass < shimmerPasses; pass++) {
|
||||
for (let x = MASCOT_WIDTH; x < BANNER_WIDTH + 6; x += 4) {
|
||||
const band: CellTint = (col) =>
|
||||
col >= x && col < x + 6 ? theme.accentBright : identityTint;
|
||||
draw(composeFrame({ wordmarkTint: band }));
|
||||
await sleep(40);
|
||||
}
|
||||
}
|
||||
// Snip: claws open and close once, sometimes twice.
|
||||
const snips = rng() < 0.4 ? 2 : 1;
|
||||
for (let snip = 0; snip < snips; snip++) {
|
||||
draw(composeFrame({ mascotRows: [...MASCOT_OPEN_ROWS, ...MASCOT_ART.slice(2)] }));
|
||||
await sleep(95);
|
||||
draw(staticBannerLines());
|
||||
await sleep(115);
|
||||
}
|
||||
draw(staticBannerLines());
|
||||
} finally {
|
||||
process.off("SIGINT", onSigint);
|
||||
process.off("SIGTERM", onSigterm);
|
||||
write("\x1b[?25h");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the OpenClaw banner: animated on rich interactive terminals, static
|
||||
* otherwise, plain title on terminals too narrow for the art.
|
||||
*/
|
||||
export async function printClawBanner(
|
||||
runtime: RuntimeEnv,
|
||||
options: ClawBannerOptions = {},
|
||||
): Promise<void> {
|
||||
const columns = options.columns ?? process.stdout.columns ?? 80;
|
||||
if (columns < BANNER_WIDTH) {
|
||||
runtime.log(`${plainTitleLine()}\n`);
|
||||
return;
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
const animate =
|
||||
(options.isTty ?? process.stdout.isTTY ?? false) &&
|
||||
(options.rich ?? isRich()) &&
|
||||
!env.CI &&
|
||||
!env.VITEST;
|
||||
if (!animate) {
|
||||
runtime.log(`${staticBannerLines().join("\n")}\n`);
|
||||
return;
|
||||
}
|
||||
await animateBanner({
|
||||
rng: options.rng ?? Math.random,
|
||||
sleep: options.sleep ?? defaultSleep,
|
||||
write: options.write ?? ((chunk) => process.stdout.write(chunk)),
|
||||
});
|
||||
(options.write ?? ((chunk: string) => process.stdout.write(chunk)))("\n");
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
staticBannerLines,
|
||||
BANNER_WIDTH,
|
||||
};
|
||||
@@ -52,6 +52,7 @@ import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logg
|
||||
import { withDiagnosticPhase } from "../../logging/diagnostic-phase.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { printClawBanner } from "../claw-banner.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-format.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
@@ -601,6 +602,12 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR
|
||||
process.env.OPENCLAW_RAW_STREAM_PATH = rawStreamPath;
|
||||
}
|
||||
|
||||
// Foreground TTY runs get the banner before the module-loading spinner;
|
||||
// managed/piped runs (launchd, tests, logs) stay banner-free.
|
||||
if (process.stdout.isTTY) {
|
||||
await printClawBanner(defaultRuntime);
|
||||
}
|
||||
|
||||
const startupTrace = createGatewayCliStartupTrace();
|
||||
|
||||
// The heaviest part of gateway startup is loading the server module tree
|
||||
|
||||
@@ -229,7 +229,7 @@ async function runGuidedOnboardingFlow(
|
||||
const onboardHelpers = await import("./onboard-helpers.js");
|
||||
const prompter = await (deps.createPrompter?.() ??
|
||||
import("../wizard/clack-prompter.js").then(({ createClackPrompter }) => createClackPrompter()));
|
||||
onboardHelpers.printWizardHeader(runtime);
|
||||
await onboardHelpers.printWizardHeader(runtime);
|
||||
await prompter.intro(t("wizard.guided.intro"));
|
||||
await prompter.note(t("wizard.guided.escapeHatches"), t("wizard.guided.welcomeTitle"));
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ describe("onboard error summaries", () => {
|
||||
});
|
||||
|
||||
describe("printWizardHeader", () => {
|
||||
const withColumns = (columns: number | undefined, run: () => void) => {
|
||||
const withColumns = async (columns: number | undefined, run: () => Promise<void>) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
||||
Object.defineProperty(process.stdout, "columns", { value: columns, configurable: true });
|
||||
try {
|
||||
run();
|
||||
await run();
|
||||
} finally {
|
||||
if (previous) {
|
||||
Object.defineProperty(process.stdout, "columns", previous);
|
||||
@@ -49,9 +49,9 @@ describe("printWizardHeader", () => {
|
||||
}
|
||||
};
|
||||
|
||||
it("prints the mascot beside the wordmark with claws above the text line", () => {
|
||||
it("prints the mascot beside the wordmark with claws above the text line", async () => {
|
||||
const log = vi.fn();
|
||||
withColumns(120, () => printWizardHeader({ log } as unknown as RuntimeEnv));
|
||||
await withColumns(120, () => printWizardHeader({ log } as unknown as RuntimeEnv));
|
||||
const output = stripAnsi(String(log.mock.calls[0]?.[0]));
|
||||
const rows = output.split("\n");
|
||||
// Claw row stands alone above the wordmark; the eye row shares a line with it.
|
||||
@@ -60,9 +60,9 @@ describe("printWizardHeader", () => {
|
||||
expect(rows[3]).toContain("██ █ ██");
|
||||
});
|
||||
|
||||
it("falls back to the plain title on narrow terminals", () => {
|
||||
it("falls back to the plain title on narrow terminals", async () => {
|
||||
const log = vi.fn();
|
||||
withColumns(50, () => printWizardHeader({ log } as unknown as RuntimeEnv));
|
||||
await withColumns(50, () => printWizardHeader({ log } as unknown as RuntimeEnv));
|
||||
const output = String(log.mock.calls[0]?.[0]);
|
||||
expect(output).toContain("OPENCLAW");
|
||||
expect(output).not.toContain("█");
|
||||
|
||||
@@ -11,12 +11,7 @@ import {
|
||||
ConnectErrorDetailCodes,
|
||||
readConnectErrorDetailCode,
|
||||
} from "../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import {
|
||||
decorativeEmoji,
|
||||
supportsDecorativeEmoji,
|
||||
} from "../../packages/terminal-core/src/decorative-emoji.js";
|
||||
import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js";
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
@@ -24,6 +19,7 @@ import {
|
||||
resolveWorkspaceAttestationPaths,
|
||||
shouldRemoveWorkspaceAttestation,
|
||||
} from "../agents/workspace.js";
|
||||
import { printClawBanner } from "../cli/claw-banner.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||
import { resolveConfigPath } from "../config/paths.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
||||
@@ -169,46 +165,9 @@ export function validateGatewayPasswordInput(value: unknown): string | undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Wizard banner art, pregenerated from pixel bitmaps (two pixel rows per
|
||||
// terminal row via ▀▄█). The mascot rows and wordmark rows are separate so the
|
||||
// mascot can take the accent color; the wordmark starts on mascot row 2, which
|
||||
// keeps the claws poking above the text line. Keep row alignment when editing.
|
||||
const WIZARD_MASCOT_ART = [
|
||||
"▄███▄ ▄███▄",
|
||||
"▀█▄█▀ ▀█▄█▀",
|
||||
" ▀▄ ▄▀",
|
||||
" ██ █ ██",
|
||||
" ▀█████▀",
|
||||
" ▄█▀ █ ▀█▄",
|
||||
] as const;
|
||||
const WIZARD_MASCOT_WIDTH = 15;
|
||||
const WIZARD_WORDMARK_ROW_OFFSET = 2;
|
||||
|
||||
const WIZARD_WORDMARK_ART = [
|
||||
"█▀▀▀█ █▀▀▀█ █▀▀▀▀ █▄ █ █▀▀▀▀ █ █▀▀▀█ █ █",
|
||||
"█ █ █▀▀▀▀ █▀▀▀ █ ▀▄█ █ █ █▀▀▀█ █▄▀▄█",
|
||||
"▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀",
|
||||
] as const;
|
||||
const WIZARD_HEADER_WIDTH = WIZARD_MASCOT_WIDTH + 3 + 47;
|
||||
|
||||
/** Prints the onboarding banner: pixel mascot beside the OPENCLAW wordmark. */
|
||||
export function printWizardHeader(runtime: RuntimeEnv) {
|
||||
// Narrow terminals (mobile SSH) would wrap the art mid-glyph; fall back to
|
||||
// the plain title line the way the compact CLI banner handles tight widths.
|
||||
const columns = process.stdout.columns ?? 80;
|
||||
if (columns < WIZARD_HEADER_WIDTH) {
|
||||
const icon = decorativeEmoji("🦞");
|
||||
runtime.log(supportsDecorativeEmoji() && icon ? `${icon} OPENCLAW ${icon}\n` : "OPENCLAW\n");
|
||||
return;
|
||||
}
|
||||
const lines = WIZARD_MASCOT_ART.map((mascotRow, index) => {
|
||||
const wordmarkRow = WIZARD_WORDMARK_ART[index - WIZARD_WORDMARK_ROW_OFFSET];
|
||||
if (!wordmarkRow) {
|
||||
return theme.accent(mascotRow);
|
||||
}
|
||||
return `${theme.accent(mascotRow.padEnd(WIZARD_MASCOT_WIDTH))} ${wordmarkRow}`;
|
||||
});
|
||||
runtime.log(`${lines.join("\n")}\n`);
|
||||
export async function printWizardHeader(runtime: RuntimeEnv): Promise<void> {
|
||||
await printClawBanner(runtime);
|
||||
}
|
||||
|
||||
/** Records wizard provenance metadata on config writes. */
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
|
||||
const { createDoctorPrompter } = await import("../commands/doctor-prompter.js");
|
||||
const { printWizardHeader } = await import("../commands/onboard-helpers.js");
|
||||
const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options });
|
||||
printWizardHeader(effectiveRuntime);
|
||||
await printWizardHeader(effectiveRuntime);
|
||||
intro("OpenClaw doctor");
|
||||
|
||||
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");
|
||||
|
||||
@@ -136,7 +136,7 @@ async function runSetupWizardOnce(
|
||||
let runtime = runtimeInput;
|
||||
runtime ??= defaultRuntime;
|
||||
const onboardHelpers = await import("../commands/onboard-helpers.js");
|
||||
onboardHelpers.printWizardHeader(runtime);
|
||||
await onboardHelpers.printWizardHeader(runtime);
|
||||
await prompter.intro(t("wizard.setup.intro"));
|
||||
|
||||
const snapshot = await readSetupConfigFileSnapshot();
|
||||
|
||||
Reference in New Issue
Block a user