diff --git a/CHANGELOG.md b/CHANGELOG.md index b02406247..eb1cee6c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI. - CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info. - Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent. +- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary. ### Fixes - Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests. diff --git a/docs/start/clawd.md b/docs/start/clawd.md index 8a6083401..ecaa9c7cb 100644 --- a/docs/start/clawd.md +++ b/docs/start/clawd.md @@ -89,6 +89,8 @@ clawdbot gateway --port 18789 Now message the assistant number from your allowlisted phone. +When onboarding finishes, we auto-open the dashboard with your gateway token and print the tokenized link. To reopen later: `clawdbot dashboard`. + ## Give the agent a workspace (AGENTS) Clawd reads operating instructions and “memory” from its workspace directory. diff --git a/docs/start/faq.md b/docs/start/faq.md index d3c5253c7..0f6714033 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -83,6 +83,10 @@ pnpm clawdbot onboard The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**. +### How do I open the dashboard after onboarding? + +The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser. + ### What runtime do I need? Node **>= 22** is required. `pnpm` is recommended; `bun` is optional. diff --git a/src/cli/program.ts b/src/cli/program.ts index 9fc9d8258..7a4383ac1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,6 +5,7 @@ import { agentsDeleteCommand, agentsListCommand, } from "../commands/agents.js"; +import { dashboardCommand } from "../commands/dashboard.js"; import { CONFIGURE_WIZARD_SECTIONS, configureCommand, @@ -482,6 +483,21 @@ export function buildProgram() { } }); + program + .command("dashboard") + .description("Open the Control UI with your current token") + .option("--no-open", "Print URL but do not launch a browser", false) + .action(async (opts) => { + try { + await dashboardCommand(defaultRuntime, { + noOpen: Boolean(opts.noOpen), + }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + program .command("reset") .description("Reset local config/state (keeps the CLI installed)") diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts new file mode 100644 index 000000000..369c6790e --- /dev/null +++ b/src/commands/dashboard.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { dashboardCommand } from "./dashboard.js"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + resolveGatewayPort: vi.fn(), + resolveControlUiLinks: vi.fn(), + detectBrowserOpenSupport: vi.fn(), + openUrl: vi.fn(), + formatControlUiSshHint: vi.fn(), + copyToClipboard: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + resolveGatewayPort: mocks.resolveGatewayPort, +})); + +vi.mock("./onboard-helpers.js", () => ({ + resolveControlUiLinks: mocks.resolveControlUiLinks, + detectBrowserOpenSupport: mocks.detectBrowserOpenSupport, + openUrl: mocks.openUrl, + formatControlUiSshHint: mocks.formatControlUiSshHint, + copyToClipboard: mocks.copyToClipboard, +})); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +function resetRuntime() { + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); +} + +function mockSnapshot(token = "abc") { + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: { gateway: { auth: { token } } }, + issues: [], + legacyIssues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.resolveControlUiLinks.mockReturnValue({ + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + }); +} + +describe("dashboardCommand", () => { + beforeEach(() => { + resetRuntime(); + mocks.readConfigFileSnapshot.mockReset(); + mocks.resolveGatewayPort.mockReset(); + mocks.resolveControlUiLinks.mockReset(); + mocks.detectBrowserOpenSupport.mockReset(); + mocks.openUrl.mockReset(); + mocks.formatControlUiSshHint.mockReset(); + mocks.copyToClipboard.mockReset(); + }); + + it("opens and copies the dashboard link by default", async () => { + mockSnapshot("abc123"); + mocks.copyToClipboard.mockResolvedValue(true); + mocks.detectBrowserOpenSupport.mockResolvedValue({ ok: true }); + mocks.openUrl.mockResolvedValue(true); + + await dashboardCommand(runtime); + + expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ + port: 18789, + bind: "loopback", + basePath: undefined, + }); + expect(mocks.copyToClipboard).toHaveBeenCalledWith( + "http://127.0.0.1:18789/?token=abc123", + ); + expect(mocks.openUrl).toHaveBeenCalledWith( + "http://127.0.0.1:18789/?token=abc123", + ); + expect(runtime.log).toHaveBeenCalledWith( + "Opened in your browser. Keep that tab to control Clawdbot.", + ); + }); + + it("prints SSH hint when browser cannot open", async () => { + mockSnapshot("shhhh"); + mocks.copyToClipboard.mockResolvedValue(false); + mocks.detectBrowserOpenSupport.mockResolvedValue({ ok: false, reason: "ssh" }); + mocks.formatControlUiSshHint.mockReturnValue("ssh hint"); + + await dashboardCommand(runtime); + + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("ssh hint"); + }); + + it("respects --no-open and skips browser attempts", async () => { + mockSnapshot(); + mocks.copyToClipboard.mockResolvedValue(true); + + await dashboardCommand(runtime, { noOpen: true }); + + expect(mocks.detectBrowserOpenSupport).not.toHaveBeenCalled(); + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith( + "Browser launch disabled (--no-open). Use the URL above.", + ); + }); +}); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts new file mode 100644 index 000000000..3a679e04c --- /dev/null +++ b/src/commands/dashboard.ts @@ -0,0 +1,61 @@ +import { resolveGatewayPort, readConfigFileSnapshot } from "../config/config.js"; +import { defaultRuntime } from "../runtime.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { + copyToClipboard, + detectBrowserOpenSupport, + formatControlUiSshHint, + openUrl, + resolveControlUiLinks, +} from "./onboard-helpers.js"; + +type DashboardOptions = { + noOpen?: boolean; +}; + +export async function dashboardCommand( + runtime: RuntimeEnv = defaultRuntime, + options: DashboardOptions = {}, +) { + const snapshot = await readConfigFileSnapshot(); + const cfg = snapshot.valid ? snapshot.config : {}; + const port = resolveGatewayPort(cfg); + const bind = cfg.gateway?.bind ?? "loopback"; + const basePath = cfg.gateway?.controlUi?.basePath; + const token = + cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? ""; + + const links = resolveControlUiLinks({ port, bind, basePath }); + const authedUrl = token + ? `${links.httpUrl}?token=${encodeURIComponent(token)}` + : links.httpUrl; + + runtime.log(`Dashboard URL: ${authedUrl}`); + + const copied = await copyToClipboard(authedUrl).catch(() => false); + runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable."); + + let opened = false; + let hint: string | undefined; + if (!options.noOpen) { + const browserSupport = await detectBrowserOpenSupport(); + if (browserSupport.ok) { + opened = await openUrl(authedUrl); + } + if (!opened) { + hint = formatControlUiSshHint({ + port, + basePath, + token: token || undefined, + }); + } + } else { + hint = "Browser launch disabled (--no-open). Use the URL above."; + } + + if (opened) { + runtime.log("Opened in your browser. Keep that tab to control Clawdbot."); + } else if (hint) { + runtime.log(hint); + } +} diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 187f4d382..3676f4dea 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -233,6 +233,28 @@ export async function openUrl(url: string): Promise { } } +export async function copyToClipboard(value: string): Promise { + const attempts: Array<{ argv: string[] }> = [ + { argv: ["pbcopy"] }, + { argv: ["xclip", "-selection", "clipboard"] }, + { argv: ["wl-copy"] }, + { argv: ["clip.exe"] }, // WSL / Windows + { argv: ["powershell", "-NoProfile", "-Command", "Set-Clipboard"] }, + ]; + for (const attempt of attempts) { + try { + const result = await runCommandWithTimeout(attempt.argv, { + timeoutMs: 3_000, + input: value, + }); + if (result.code === 0 && !result.killed) return true; + } catch { + // keep trying the next fallback + } + } + return false; +} + export async function ensureWorkspaceAndSessions( workspaceDir: string, runtime: RuntimeEnv, diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 1f9f854e2..9b60b4092 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -735,10 +735,13 @@ export async function runOnboardingWizard( "Optional apps", ); + const controlUiBasePath = + nextConfig.gateway?.controlUi?.basePath ?? + baseConfig.gateway?.controlUi?.basePath; const links = resolveControlUiLinks({ bind, port, - basePath: baseConfig.gateway?.controlUi?.basePath, + basePath: controlUiBasePath, }); const tokenParam = authMode === "token" && gatewayToken @@ -748,7 +751,7 @@ export async function runOnboardingWizard( const gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token: authMode === "token" ? gatewayToken : undefined, - password: authMode === "password" ? baseConfig.gateway?.auth?.password : "", + password: authMode === "password" ? nextConfig.gateway?.auth?.password : "", }); const gatewayStatusLine = gatewayProbe.ok ? "Gateway: reachable" @@ -802,29 +805,16 @@ export async function runOnboardingWizard( await prompter.note( formatControlUiSshHint({ port, - basePath: baseConfig.gateway?.controlUi?.basePath, + basePath: controlUiBasePath, token: authMode === "token" ? gatewayToken : undefined, }), "Open Control UI", ); } else { - const wantsOpen = await prompter.confirm({ - message: "Open Control UI now?", - initialValue: true, - }); - if (wantsOpen) { - const opened = await openUrl(`${links.httpUrl}${tokenParam}`); - if (!opened) { - await prompter.note( - formatControlUiSshHint({ - port, - basePath: baseConfig.gateway?.controlUi?.basePath, - token: authMode === "token" ? gatewayToken : undefined, - }), - "Open Control UI", - ); - } - } + await prompter.note( + "Opening Control UI automatically after onboarding (no extra prompts).", + "Open Control UI", + ); } } } else if (opts.skipUi) { @@ -844,5 +834,46 @@ export async function runOnboardingWizard( "Security", ); - await prompter.outro("Onboarding complete."); + const shouldOpenControlUi = + !opts.skipUi && authMode === "token" && Boolean(gatewayToken); + let controlUiOpened = false; + let controlUiOpenHint: string | undefined; + if (shouldOpenControlUi) { + const browserSupport = await detectBrowserOpenSupport(); + if (browserSupport.ok) { + controlUiOpened = await openUrl(authedUrl); + if (!controlUiOpened) { + controlUiOpenHint = formatControlUiSshHint({ + port, + basePath: controlUiBasePath, + token: gatewayToken, + }); + } + } else { + controlUiOpenHint = formatControlUiSshHint({ + port, + basePath: controlUiBasePath, + token: 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", + ); + } + + await prompter.outro( + controlUiOpened + ? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot." + : "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.", + ); }