feat: refine onboarding hatch flow

This commit is contained in:
Peter Steinberger
2026-01-23 04:32:13 +00:00
parent 64be2b2cd1
commit 5d0d9e6323
4 changed files with 105 additions and 16 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot
- Docs: add /model allowlist troubleshooting note. (#1405) - Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla. - UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli. - Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.

View File

@@ -217,6 +217,19 @@ export async function openUrl(url: string): Promise<boolean> {
} }
} }
export async function openUrlInBackground(url: string): Promise<boolean> {
if (process.platform !== "darwin") return false;
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv || resolved.command !== "open") return false;
const command = ["open", "-g", url];
try {
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
return true;
} catch {
return false;
}
}
export async function ensureWorkspaceAndSessions( export async function ensureWorkspaceAndSessions(
workspaceDir: string, workspaceDir: string,
runtime: RuntimeEnv, runtime: RuntimeEnv,

View File

@@ -13,6 +13,7 @@ import {
detectBrowserOpenSupport, detectBrowserOpenSupport,
formatControlUiSshHint, formatControlUiSshHint,
openUrl, openUrl,
openUrlInBackground,
probeGatewayReachable, probeGatewayReachable,
waitForGatewayReachable, waitForGatewayReachable,
resolveControlUiLinks, resolveControlUiLinks,
@@ -282,6 +283,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
"Control UI", "Control UI",
); );
let controlUiOpened = false;
let controlUiOpenHint: string | undefined;
let seededInBackground = false;
let hatchChoice: "tui" | "web" | "later" | null = null;
if (!opts.skipUi && gatewayProbe.ok) { if (!opts.skipUi && gatewayProbe.ok) {
if (hasBootstrap) { if (hasBootstrap) {
await prompter.note( await prompter.note(
@@ -293,11 +299,27 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
].join("\n"), ].join("\n"),
"Start TUI (best option!)", "Start TUI (best option!)",
); );
const wantsTui = await prompter.confirm({ await prompter.note(
message: "Do you want to hatch your bot now?", [
initialValue: true, "Gateway token: shared auth for the Gateway + Control UI.",
}); "Stored in: ~/.clawdbot/clawdbot.json (gateway.auth.token) or CLAWDBOT_GATEWAY_TOKEN.",
if (wantsTui) { "Web UI stores a copy in this browser's localStorage (clawdbot.control.settings.v1).",
`Get the tokenized link anytime: ${formatCliCommand("clawdbot dashboard --no-open")}`,
].join("\n"),
"Token",
);
hatchChoice = (await prompter.select({
message: "How do you want to hatch your bot?",
options: [
{ value: "tui", label: "Hatch in TUI (recommended)" },
{ value: "web", label: "Open the Web UI" },
{ value: "later", label: "Do this later" },
],
initialValue: "tui",
})) as "tui" | "web" | "later";
if (hatchChoice === "tui") {
await runTui({ await runTui({
url: links.wsUrl, url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined, token: settings.authMode === "token" ? settings.gatewayToken : undefined,
@@ -306,6 +328,52 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
deliver: false, deliver: false,
message: "Wake up, my friend!", message: "Wake up, my friend!",
}); });
if (settings.authMode === "token" && settings.gatewayToken) {
seededInBackground = await openUrlInBackground(authedUrl);
}
if (seededInBackground) {
await prompter.note(
`Web UI seeded in the background. Open later with: ${formatCliCommand(
"clawdbot dashboard --no-open",
)}`,
"Web UI",
);
}
} else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.gatewayToken,
});
}
} else {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.gatewayToken,
});
}
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
controlUiOpened
? "Opened in your browser. Keep that tab to control Clawdbot."
: "Copy/paste this URL in a browser on this machine to control Clawdbot.",
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
"Dashboard ready",
);
} else {
await prompter.note(
`When you're ready: ${formatCliCommand("clawdbot dashboard --no-open")}`,
"Later",
);
} }
} else { } else {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
@@ -342,9 +410,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
); );
const shouldOpenControlUi = const shouldOpenControlUi =
!opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken); !opts.skipUi &&
let controlUiOpened = false; settings.authMode === "token" &&
let controlUiOpenHint: string | undefined; Boolean(settings.gatewayToken) &&
hatchChoice === null;
if (shouldOpenControlUi) { if (shouldOpenControlUi) {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) { if (browserSupport.ok) {
@@ -406,9 +475,16 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
"Web search (optional)", "Web search (optional)",
); );
await prompter.note(
'What now: https://clawd.bot/showcase ("What People Are Building").',
"What now",
);
await prompter.outro( await prompter.outro(
controlUiOpened controlUiOpened
? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot." ? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot."
: "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.", : seededInBackground
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above."
: "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.",
); );
} }

View File

@@ -177,19 +177,19 @@ describe("runOnboardingWizard", () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}"); await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
const confirm: WizardPrompter["confirm"] = vi.fn(async (opts) => { const select: WizardPrompter["select"] = vi.fn(async (opts) => {
if (opts.message === "Do you want to hatch your bot now?") return true; if (opts.message === "How do you want to hatch your bot?") return "tui";
return opts.initialValue ?? false; return "quickstart";
}); });
const prompter: WizardPrompter = { const prompter: WizardPrompter = {
intro: vi.fn(async () => {}), intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}), outro: vi.fn(async () => {}),
note: vi.fn(async () => {}), note: vi.fn(async () => {}),
select: vi.fn(async () => "quickstart"), select,
multiselect: vi.fn(async () => []), multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""), text: vi.fn(async () => ""),
confirm, confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
}; };
@@ -267,8 +267,7 @@ describe("runOnboardingWizard", () => {
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
expect(calls.length).toBeGreaterThan(0); expect(calls.length).toBeGreaterThan(0);
const lastCall = calls[calls.length - 1]; expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true);
expect(lastCall?.[1]).toBe("Web search (optional)");
} finally { } finally {
if (prevBraveKey === undefined) { if (prevBraveKey === undefined) {
delete process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY;