From d7f971498f25dd106d25c9ed20514fb2a43693ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 06:58:46 +0000 Subject: [PATCH 01/13] =?UTF-8?q?chore:=20add=20task=20for=20wizard=20UI?= =?UTF-8?q?=20=E2=86=92=20config-first=20TUI=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://claude.ai/code/session_01KVYE98eDHVsV1pZ5Vn33xy --- .../TASK.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md diff --git a/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md b/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md new file mode 100644 index 0000000..1b50a9e --- /dev/null +++ b/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md @@ -0,0 +1,50 @@ +# Wizard UI → Config-First TUI with Headless Provisioning + +## Status: In Progress + +## Scope + +Replace the 9-step interactive wizard with a config-building TUI that produces an `InstanceConfig` and delegates to the existing headless pipeline. This unifies two code paths into one, covers the full schema, and provides a better UX. + +**In scope:** +- Two-pane TUI (form + contextual sidebar) +- Collapsible sections for all InstanceConfig fields +- Inline Zod validation per field/section +- Review screen with validation summary +- Provisioning progress monitor (Ink-based, wrapping headless pipeline) +- Completion screen with URLs and next steps +- Cleanup of old wizard steps + +**Out of scope:** +- Changing headless pipeline logic +- New capabilities +- Interactive Tailscale login (require auth key) + +## Plan + +1. Add `HeadlessCallbacks` and `runHeadlessFromConfig()` to headless.ts +2. Build TUI components: FormField, FormSection, Sidebar, ConfigReview, ProvisionMonitor, CompletionScreen +3. Build ConfigBuilder step (main form orchestrator) +4. Rewrite App.tsx (4-phase flow) and create.ts (unified path) +5. Delete old wizard steps and unused code + +## Steps + +- [ ] Create task directory and TASK.md +- [ ] Phase 1: Callbacks on headless pipeline +- [ ] Phase 2: TUI components +- [ ] Phase 3: ConfigBuilder step +- [ ] Phase 4: App.tsx and create.ts rewrite +- [ ] Phase 5: Cleanup old steps +- [ ] Validate: lint, format, tests +- [ ] Commit and push + +## Notes + +- `ink-select-input` v6 already in package.json but unused — will use for provider type, tailscale mode +- Existing Zod schemas in `packages/types/src/schemas/` can be reused directly for inline validation +- The headless pipeline already handles all provisioning stages including bootstrap — the wizard's post-exit block in create.ts is redundant + +## Outcome + +(To be filled on completion) From baaadc74f1cdc8a8a268d292f2e0b979be2dc956 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 07:09:00 +0000 Subject: [PATCH 02/13] feat: replace wizard with config-first TUI and unified headless pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 9-step interactive wizard with a two-pane config-building TUI that produces an InstanceConfig and delegates to the existing headless provisioning pipeline. This unifies the interactive and headless create paths into a single execution flow. New TUI features: - Two-pane layout: form on left, contextual help sidebar on right - Collapsible sections for all InstanceConfig fields (Provider, Services, Network, Agent Identity, Telegram) - Inline Zod validation per field and section - Review screen with validation summary before creating - Full schema coverage (provider, bootstrap, telegram — previously headless-only) Architecture changes: - Extract runHeadlessFromConfig() from headless.ts with HeadlessCallbacks for structured progress events (onStage, onStep, onLine, onError) - New Ink ProvisionMonitor component wraps headless pipeline with real-time progress tree, spinners, and log tail - App.tsx simplified to 4 phases: prereqs → config → provision → done - create.ts reduced from 270 lines to 60 — all post-wizard logic removed Deleted 9 old wizard steps (configure, credentials, create-vm, provision-status, credential-setup, onboard, finish, welcome, host-setup) replaced by 2 new steps (prereq-check, config-builder) and 4 new components (provision-monitor, completion-screen, config-review, sidebar). https://claude.ai/code/session_01KVYE98eDHVsV1pZ5Vn33xy --- packages/cli/src/app.tsx | 174 ++-- packages/cli/src/commands/create.ts | 264 +----- .../cli/src/components/completion-screen.tsx | 69 ++ packages/cli/src/components/config-review.tsx | 178 ++++ packages/cli/src/components/form-field.tsx | 50 + packages/cli/src/components/form-section.tsx | 66 ++ .../cli/src/components/provision-monitor.tsx | 156 ++++ packages/cli/src/components/sidebar.tsx | 200 ++++ packages/cli/src/steps/config-builder.tsx | 863 ++++++++++++++++++ packages/cli/src/steps/configure.tsx | 127 --- packages/cli/src/steps/create-vm.tsx | 92 -- packages/cli/src/steps/credential-setup.tsx | 110 --- packages/cli/src/steps/credentials.tsx | 129 --- packages/cli/src/steps/finish.tsx | 71 -- packages/cli/src/steps/host-setup.tsx | 74 -- packages/cli/src/steps/onboard.tsx | 56 -- packages/cli/src/steps/prereq-check.tsx | 117 +++ packages/cli/src/steps/provision-status.tsx | 92 -- packages/cli/src/steps/welcome.tsx | 79 -- packages/cli/src/types.ts | 17 - packages/host-core/src/headless.ts | 163 ++-- packages/host-core/src/index.ts | 6 +- .../TASK.md | 2 + 23 files changed, 1891 insertions(+), 1264 deletions(-) create mode 100644 packages/cli/src/components/completion-screen.tsx create mode 100644 packages/cli/src/components/config-review.tsx create mode 100644 packages/cli/src/components/form-field.tsx create mode 100644 packages/cli/src/components/form-section.tsx create mode 100644 packages/cli/src/components/provision-monitor.tsx create mode 100644 packages/cli/src/components/sidebar.tsx create mode 100644 packages/cli/src/steps/config-builder.tsx delete mode 100644 packages/cli/src/steps/configure.tsx delete mode 100644 packages/cli/src/steps/create-vm.tsx delete mode 100644 packages/cli/src/steps/credential-setup.tsx delete mode 100644 packages/cli/src/steps/credentials.tsx delete mode 100644 packages/cli/src/steps/finish.tsx delete mode 100644 packages/cli/src/steps/host-setup.tsx delete mode 100644 packages/cli/src/steps/onboard.tsx create mode 100644 packages/cli/src/steps/prereq-check.tsx delete mode 100644 packages/cli/src/steps/provision-status.tsx delete mode 100644 packages/cli/src/steps/welcome.tsx diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index 54f63e1..878fdb4 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -1,150 +1,90 @@ -import React, { useState } from "react"; -import { Box, Text } from "ink"; -import { Welcome } from "./steps/welcome.js"; -import { Configure } from "./steps/configure.js"; -import { Credentials } from "./steps/credentials.js"; -import { HostSetup } from "./steps/host-setup.js"; -import { CreateVM } from "./steps/create-vm.js"; -import { ProvisionStatus } from "./steps/provision-status.js"; -import { CredentialSetup } from "./steps/credential-setup.js"; -import { Finish } from "./steps/finish.js"; -import { Onboard } from "./steps/onboard.js"; +import React, { useState, useEffect, useRef } from "react"; +import { Box, Text, useApp } from "ink"; +import { PrereqCheck } from "./steps/prereq-check.js"; +import { ConfigBuilder } from "./steps/config-builder.js"; +import { ProvisionMonitor } from "./components/provision-monitor.js"; +import { CompletionScreen } from "./components/completion-screen.js"; import { useVerboseMode } from "./hooks/use-verbose-mode.js"; import { VerboseContext } from "./hooks/verbose-context.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver, CleanupTarget } from "@clawctl/host-core"; -import type { WizardStep, PrereqStatus, CredentialConfig } from "./types.js"; +import type { InstanceConfig } from "@clawctl/types"; +import type { VMDriver, HeadlessResult } from "@clawctl/host-core"; -const PROCESS_STEPS: WizardStep[] = ["host-setup", "create-vm", "provision", "credential-setup"]; +type AppPhase = "prereqs" | "config" | "provision" | "done" | "error"; + +export interface AppResult { + action: "created"; + result: HeadlessResult; +} interface AppProps { driver: VMDriver; - /** Mutable ref set when VM creation starts, so the caller can clean up on interrupt. */ - creationTarget?: CleanupTarget; } -export function App({ driver, creationTarget }: AppProps) { - const [step, setStep] = useState("welcome"); +export function App({ driver }: AppProps) { + const { exit } = useApp(); + const [phase, setPhase] = useState("prereqs"); const { verbose } = useVerboseMode(); - const [prereqs, setPrereqs] = useState({ - isMacOS: false, - isArm64: false, - hasHomebrew: false, - hasVMBackend: false, - }); - const [config, setConfig] = useState({ - projectDir: "", - vmName: "", - cpus: 4, - memory: "8GiB", - disk: "50GiB", - }); - const [credentialConfig, setCredentialConfig] = useState({}); - const [onboardSkipped, setOnboardSkipped] = useState(false); + const [config, setConfig] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const exited = useRef(false); - const showHint = PROCESS_STEPS.includes(step); + // Exit Ink on completion or error so waitUntilExit() resolves + useEffect(() => { + if (phase === "done" && result && !exited.current) { + exited.current = true; + // Brief delay so the completion screen renders + setTimeout(() => { + exit({ action: "created", result } as AppResult); + }, 1000); + } + }, [phase, result]); return ( - {step === "welcome" && ( - { - setPrereqs(p); - if (!p.isMacOS || !p.hasHomebrew) { - return; - } - setStep(p.hasVMBackend ? "configure" : "host-setup"); - }} - /> - )} - - {step === "host-setup" && ( - { - setPrereqs(updated); - setStep("configure"); - }} - /> - )} - - {step === "configure" && ( - { - setConfig(c); - if (creationTarget) { - creationTarget.vmName = c.vmName; - creationTarget.projectDir = c.projectDir; - } - setStep("credentials"); - }} - /> + {phase === "prereqs" && ( + setPhase("config")} /> )} - {step === "credentials" && ( - { - setCredentialConfig(creds); - setStep("create-vm"); + {phase === "config" && ( + { + setConfig(cfg); + setPhase("provision"); }} /> )} - {step === "create-vm" && ( - { + setResult(res); + setPhase("done"); }} - onComplete={() => setStep("provision")} - /> - )} - - {step === "provision" && ( - setStep("credential-setup")} - /> - )} - - {step === "credential-setup" && ( - { - setCredentialConfig(creds); - setStep("onboard"); + onError={(err) => { + setError(err.message); + setPhase("error"); }} /> )} - {step === "onboard" && ( - { - setOnboardSkipped(skipped); - setStep("finish"); - }} - /> - )} + {phase === "done" && result && } - {step === "finish" && ( - + {phase === "error" && ( + + + {"\u2717"} Provisioning failed + + + {error} + + )} - {showHint && ( + {(phase === "prereqs" || phase === "provision") && ( Press [v] to {verbose ? "hide" : "show"} process logs diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index fe70b80..3a41383 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,76 +1,26 @@ -import { writeFile } from "fs/promises"; import { openSync } from "node:fs"; import { ReadStream } from "node:tty"; -import { join } from "path"; import type { VMDriver } from "@clawctl/host-core"; -import { - addInstance, - getTailscaleHostname, - cleanupVM, - runHeadless, - extractGatewayToken, - loadRegistry, - saveRegistry, - BIN_NAME, -} from "@clawctl/host-core"; -import type { RegistryEntry, CleanupTarget } from "@clawctl/host-core"; -import { GATEWAY_PORT } from "@clawctl/types"; +import { addInstance, runHeadless } from "@clawctl/host-core"; +import type { RegistryEntry, HeadlessResult } from "@clawctl/host-core"; /** * Run the headless create path: load config, provision, register. */ export async function runCreateHeadless(driver: VMDriver, configPath: string): Promise { const result = await runHeadless(driver, configPath); - - const entry: RegistryEntry = { - name: result.name, - projectDir: result.projectDir, - vmName: result.vmName, - driver: result.driver, - createdAt: new Date().toISOString(), - providerType: result.providerType, - gatewayPort: result.gatewayPort, - tailscaleUrl: result.tailscaleUrl, - }; - await addInstance(entry); + await registerInstance(result, driver.name); } /** - * Run the interactive wizard create path. - * Returns when the wizard exits (either via onboard or finish). + * Run the interactive TUI create path. + * The TUI collects an InstanceConfig, then delegates to the headless pipeline. */ export async function runCreateWizard(driver: VMDriver): Promise { const React = (await import("react")).default; const { render } = await import("ink"); const { App } = await import("../app.js"); - type OnboardResult = { - action: "onboard"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; - }; - type FinishResult = { - action: "finish"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; - }; - - // Track VM creation so we can clean up on interrupt. - // App sets vmName/projectDir when entering the create-vm step. - const creationTarget: CleanupTarget = { vmName: "", projectDir: "" }; - - // SIGTERM handler (SIGINT is caught by Ink in raw mode, not delivered as a signal) - const onTerm = async () => { - if (creationTarget.vmName) { - console.error("\nCaught SIGTERM, cleaning up..."); - await cleanupVM(driver, creationTarget.vmName, creationTarget.projectDir); - } - process.exit(143); - }; - process.on("SIGTERM", onTerm); - // Give Ink its own stdin stream via /dev/tty so it never touches // process.stdin. After Ink exits, the subprocess can inherit // process.stdin cleanly without competing for bytes on fd 0. @@ -82,190 +32,34 @@ export async function runCreateWizard(driver: VMDriver): Promise { } const renderOpts = inkStdin ? { stdin: inkStdin } : undefined; - const instance = render(React.createElement(App, { driver, creationTarget }), renderOpts); - const result = await instance.waitUntilExit(); + const instance = render(React.createElement(App, { driver }), renderOpts); + const exitResult = await instance.waitUntilExit(); - // Destroy Ink's private stdin — doesn't affect process.stdin + // Destroy Ink's private stdin inkStdin?.destroy(); - // If the wizard was interrupted (Ctrl+C → Ink exits without an action result), - // clean up any partially-created VM and exit. - const isNormalExit = result && typeof result === "object" && "action" in result; - if (!isNormalExit) { - process.off("SIGTERM", onTerm); - if (creationTarget.vmName) { - console.error("\nInterrupted, cleaning up..."); - await cleanupVM(driver, creationTarget.vmName, creationTarget.projectDir); - } - return; - } - - // Wizard completed normally — stop cleaning up on SIGTERM. - // From here the VM is intentional; post-wizard work (onboarding etc.) - // is retryable and shouldn't trigger VM deletion. - process.off("SIGTERM", onTerm); - - // Write minimal clawctl.json for wizard-created instances - const writeMinimalConfig = async (vmName: string, projectDir: string) => { - const minimal = { name: vmName, project: projectDir }; - await writeFile(join(projectDir, "clawctl.json"), JSON.stringify(minimal, null, 2) + "\n"); - }; - - // Register the instance - const registerWizardInstance = async ( - vmName: string, - projectDir: string, - tailscaleUrl?: string, - ) => { - const entry: RegistryEntry = { - name: vmName, - projectDir, - vmName, - driver: driver.name, - createdAt: new Date().toISOString(), - gatewayPort: GATEWAY_PORT, - tailscaleUrl, - }; - await addInstance(entry); - }; - + // Register instance if provisioning completed successfully if ( - result && - typeof result === "object" && - "action" in result && - (result as OnboardResult).action === "onboard" - ) { - const { vmName, projectDir, tailscaleMode } = result as OnboardResult; - - await registerWizardInstance(vmName, projectDir); - await writeMinimalConfig(vmName, projectDir); - - console.log(""); - console.log("--- OpenClaw Onboarding (running inside VM) ---"); - console.log(""); - - try { - const onboardResult = await driver.execInteractive(vmName, "openclaw onboard --skip-daemon"); - console.log(""); - - if (onboardResult.exitCode !== 0) { - console.log(`Warning: Onboarding exited with code ${onboardResult.exitCode}`); - console.log(` You can retry: ${BIN_NAME} oc onboard`); - } else { - console.log("Installing gateway service..."); - const installResult = await driver.exec( - vmName, - "openclaw daemon install --runtime node --force", - ); - if (installResult.exitCode !== 0) { - console.log("Warning: Gateway service install failed. You can retry:"); - console.log(` ${BIN_NAME} oc daemon install --runtime node --force`); - } else { - console.log("Starting gateway..."); - await driver.exec(vmName, "openclaw daemon start"); - await driver.exec(vmName, "openclaw config set tools.profile full"); - await driver.exec( - vmName, - "openclaw config set agents.defaults.workspace /mnt/project/data/workspace", - ); - // Configure Tailscale gateway mode + allowedOrigins before restart - if (tailscaleMode && tailscaleMode !== "off") { - await driver.exec( - vmName, - `openclaw config set gateway.tailscale.mode ${tailscaleMode}`, - ); - const tsHostname = await getTailscaleHostname(driver, vmName); - if (tsHostname) { - const tsUrl = `https://${tsHostname}`; - await driver.exec( - vmName, - `openclaw config set gateway.controlUi.allowedOrigins '["${tsUrl}"]'`, - ); - } - } - - await driver.exec(vmName, "openclaw daemon restart"); - - const envResult = await driver.exec( - vmName, - "systemctl --user show openclaw-gateway.service -p Environment", - ); - const token = extractGatewayToken(envResult.stdout); - - const doctorResult = await driver.exec(vmName, "openclaw doctor"); - if (doctorResult.exitCode === 0) { - console.log("OpenClaw setup complete — openclaw doctor passed"); - } else { - console.log("Warning: Setup finished but openclaw doctor reported issues"); - } - - console.log(""); - if (token) { - console.log(`Dashboard: http://localhost:${GATEWAY_PORT}/#token=${token}`); - } else { - console.log(`Dashboard: http://localhost:${GATEWAY_PORT}`); - } - console.log(`Enter VM: ${BIN_NAME} shell`); - - // Update registry with Tailscale URL if serve/funnel mode - if (tailscaleMode && tailscaleMode !== "off") { - const tsHostnameForRegistry = await getTailscaleHostname(driver, vmName); - if (tsHostnameForRegistry) { - const tsBaseUrl = `https://${tsHostnameForRegistry}`; - console.log(`Tailscale: ${tsBaseUrl}`); - console.log(" First tailnet connection requires device approval:"); - console.log(` ${BIN_NAME} oc devices list`); - console.log(` ${BIN_NAME} oc devices approve `); - const registry = await loadRegistry(); - if (registry.instances[vmName]) { - registry.instances[vmName].tailscaleUrl = tsBaseUrl; - await saveRegistry(registry); - } - } - } - - const bootstrapCheck = await driver.exec( - vmName, - "test -f ~/.openclaw/workspace/BOOTSTRAP.md && echo yes || echo no", - ); - if (bootstrapCheck.stdout.trim() === "yes") { - console.log(""); - console.log("--- First conversation (the agent wants to meet you) ---"); - console.log(""); - try { - await driver.execInteractive( - vmName, - "openclaw tui --message 'You just woke up. Time to figure out who you are.'", - ); - } catch { - // User Ctrl-C'd out of the first conversation — that's fine - } - } - - // AGENTS.md managed section is now written VM-side during provision-workspace. - // See @clawctl/capabilities runner.ts. - } - } - } catch (err) { - console.error("Failed to run onboarding:", err); - console.log(` You can retry: ${BIN_NAME} oc onboard`); - } - } else if ( - result && - typeof result === "object" && - "action" in result && - (result as FinishResult).action === "finish" + exitResult && + typeof exitResult === "object" && + "action" in exitResult && + (exitResult as { action: string }).action === "created" ) { - const { vmName, projectDir, tailscaleMode } = result as FinishResult; - - // Query Tailscale URL if serve/funnel mode was selected - let tailscaleUrl: string | undefined; - if (tailscaleMode && tailscaleMode !== "off") { - const tsHostname = await getTailscaleHostname(driver, vmName); - if (tsHostname) tailscaleUrl = `https://${tsHostname}`; - } - - await registerWizardInstance(vmName, projectDir, tailscaleUrl); - await writeMinimalConfig(vmName, projectDir); + const { result } = exitResult as { action: string; result: HeadlessResult }; + await registerInstance(result, driver.name); } } + +async function registerInstance(result: HeadlessResult, driverName: string): Promise { + const entry: RegistryEntry = { + name: result.name, + projectDir: result.projectDir, + vmName: result.vmName, + driver: driverName, + createdAt: new Date().toISOString(), + providerType: result.providerType, + gatewayPort: result.gatewayPort, + tailscaleUrl: result.tailscaleUrl, + }; + await addInstance(entry); +} diff --git a/packages/cli/src/components/completion-screen.tsx b/packages/cli/src/components/completion-screen.tsx new file mode 100644 index 0000000..848dc49 --- /dev/null +++ b/packages/cli/src/components/completion-screen.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Text, Box } from "ink"; +import type { HeadlessResult } from "@clawctl/host-core"; +import { BIN_NAME } from "@clawctl/host-core"; + +interface CompletionScreenProps { + result: HeadlessResult; +} + +export function CompletionScreen({ result }: CompletionScreenProps) { + const dashboardUrl = result.gatewayToken + ? `http://localhost:${result.gatewayPort}/#token=${result.gatewayToken}` + : `http://localhost:${result.gatewayPort}`; + + return ( + + + + {"\u2713"} {result.name} is ready + + + + + + + Dashboard + + {dashboardUrl} + + {result.tailscaleUrl && ( + + + Tailscale + + {result.tailscaleUrl} + + )} + + + Config + + {result.projectDir}/clawctl.json + + + + + Next steps: + + {" "} + {BIN_NAME} shell Enter the VM + + + {" "} + {BIN_NAME} oc dashboard Open the dashboard + + + {" "} + {BIN_NAME} status Check instance health + + {!result.providerType && ( + + {" "} + {BIN_NAME} oc onboard Configure a provider + + )} + + + ); +} diff --git a/packages/cli/src/components/config-review.tsx b/packages/cli/src/components/config-review.tsx new file mode 100644 index 0000000..61945ac --- /dev/null +++ b/packages/cli/src/components/config-review.tsx @@ -0,0 +1,178 @@ +import React from "react"; +import { Text, Box } from "ink"; +import type { InstanceConfig } from "@clawctl/types"; + +interface ConfigReviewProps { + config: InstanceConfig; + validationErrors: string[]; + validationWarnings: string[]; + focused?: boolean; +} + +function MaskedValue({ value, label }: { value?: string; label?: string }) { + if (!value) + return ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + ); + const masked = value.slice(0, 6) + "\u2022".repeat(Math.min(value.length - 6, 18)); + return ( + + {label ? `${label} ` : ""} + {masked} + + ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +export function ConfigReview({ config, validationErrors, validationWarnings }: ConfigReviewProps) { + const resources = config.resources ?? {}; + const cpus = resources.cpus ?? 4; + const memory = resources.memory ?? "8GiB"; + const disk = resources.disk ?? "50GiB"; + + const bootstrapSummary = config.bootstrap + ? typeof config.bootstrap === "string" + ? "(custom prompt)" + : `${config.bootstrap.agent.name}${config.bootstrap.agent.context ? ` \u2014 "${config.bootstrap.agent.context.slice(0, 40)}${config.bootstrap.agent.context.length > 40 ? "..." : ""}"` : ""}` + : null; + + return ( + + + Review Configuration + + + + + {config.name} + + + {config.project} + + + + {cpus} cpu {"\u00b7"} {memory} {"\u00b7"} {disk} + + + + + + + {config.provider ? ( + {config.provider.type} + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + {config.provider?.apiKey && ( + + + + )} + + + + + {config.services?.onePassword ? ( + {"\u2713"} configured + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + {config.network?.tailscale ? ( + + {"\u2713"} {config.network.tailscale.mode ?? "serve"} (auth key provided) + + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + + + + {bootstrapSummary ? ( + {bootstrapSummary} + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + {config.telegram ? ( + {"\u2713"} configured + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + + 0 ? "red" : "green"} + marginTop={1} + paddingX={1} + > + Validation + {validationErrors.length === 0 && validationWarnings.length === 0 && ( + {"\u2713"} All required fields are set + )} + {validationErrors.map((err, i) => ( + + {" "} + {"\u2717"} {err} + + ))} + {validationWarnings.map((warn, i) => ( + + {" "} + {"\u2139"} {warn} + + ))} + {config.services?.onePassword && ( + {"\u2139"} 1Password token will be validated during provisioning + )} + {config.network?.tailscale && ( + {"\u2139"} Tailscale auth key will be used (no interactive login) + )} + + {" "} + {"\u2139"} Config will be saved to {config.project}/clawctl.json + + + + + {validationErrors.length > 0 ? ( + [Esc] Back to editor (fix errors first) + ) : ( + + [Enter] Create instance {"\u00b7"} [Esc] Back to editor {"\u00b7"} [S] Save config only + + )} + + + ); +} diff --git a/packages/cli/src/components/form-field.tsx b/packages/cli/src/components/form-field.tsx new file mode 100644 index 0000000..6673ff9 --- /dev/null +++ b/packages/cli/src/components/form-field.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Text, Box } from "ink"; + +export type FieldStatus = "idle" | "focused" | "editing" | "valid" | "error"; + +interface FormFieldProps { + label: string; + value: string; + status?: FieldStatus; + error?: string; + masked?: boolean; + placeholder?: string; + dimValue?: boolean; +} + +export function FormField({ + label, + value, + status = "idle", + error, + masked = false, + placeholder, + dimValue = false, +}: FormFieldProps) { + const isFocused = status === "focused" || status === "editing"; + const displayValue = masked && value ? "\u2022".repeat(Math.min(value.length, 24)) : value; + + return ( + + + + {isFocused ? "\u25b8 " : " "} + + + {label} + + {displayValue ? ( + {displayValue} + ) : placeholder ? ( + {placeholder} + ) : null} + + {error && status === "error" && ( + + {error} + + )} + + ); +} diff --git a/packages/cli/src/components/form-section.tsx b/packages/cli/src/components/form-section.tsx new file mode 100644 index 0000000..c89575f --- /dev/null +++ b/packages/cli/src/components/form-section.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { Text, Box } from "ink"; + +export type SectionStatus = "unconfigured" | "configured" | "error"; + +interface FormSectionProps { + label: string; + status: SectionStatus; + summary?: string; + error?: string; + focused?: boolean; + expanded?: boolean; + children?: React.ReactNode; +} + +export function FormSection({ + label, + status, + summary, + error, + focused = false, + expanded = false, + children, +}: FormSectionProps) { + const icon = expanded ? "\u25be" : "\u25b8"; + const statusIcon = + status === "configured" ? ( + {"\u2713"} + ) : status === "error" ? ( + {"\u2717"} + ) : null; + + const statusText = + status === "unconfigured" ? ( + + {"\u2500\u2500"} none {"\u2500\u2500"} + + ) : ( + {summary} + ); + + return ( + + + + {focused ? icon : " " + icon.replace("\u25b8", "\u25b8").replace("\u25be", "\u25be")}{" "} + + {statusIcon} + + {label} + + {!expanded && statusText} + + {error && status === "error" && !expanded && ( + + {error} + + )} + {expanded && ( + + {children} + + )} + + ); +} diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx new file mode 100644 index 0000000..666ec0b --- /dev/null +++ b/packages/cli/src/components/provision-monitor.tsx @@ -0,0 +1,156 @@ +import React, { useState, useEffect, useCallback, useContext } from "react"; +import { Text, Box, useInput } from "ink"; +import { Spinner } from "./spinner.js"; +import { LogOutput } from "./log-output.js"; +import { VerboseContext } from "../hooks/verbose-context.js"; +import type { VMDriver } from "@clawctl/host-core"; +import { runHeadlessFromConfig } from "@clawctl/host-core"; +import type { + HeadlessResult, + HeadlessCallbacks, + HeadlessStage, + StageStatus, +} from "@clawctl/host-core"; +import type { InstanceConfig } from "@clawctl/types"; + +interface StageInfo { + label: string; + status: StageStatus; + detail?: string; +} + +const STAGE_LABELS: Record = { + prereqs: "Prerequisites", + provision: "Provisioning VM", + verify: "Verifying installation", + onepassword: "Setting up 1Password", + secrets: "Resolving secrets", + tailscale: "Connecting Tailscale", + bootstrap: "Bootstrapping OpenClaw", + done: "Complete", +}; + +interface ProvisionMonitorProps { + driver: VMDriver; + config: InstanceConfig; + onComplete: (result: HeadlessResult) => void; + onError: (error: Error) => void; +} + +export function ProvisionMonitor({ driver, config, onComplete, onError }: ProvisionMonitorProps) { + const verbose = useContext(VerboseContext); + const [stages, setStages] = useState>(() => new Map()); + const [steps, setSteps] = useState([]); + const [logs, setLogs] = useState([]); + const [showLogs, setShowLogs] = useState(verbose); + + useInput((input) => { + if (input === "v") setShowLogs((s) => !s); + }); + + const onStage = useCallback((stage: HeadlessStage, status: StageStatus, detail?: string) => { + setStages((prev) => { + const next = new Map(prev); + next.set(stage, { label: STAGE_LABELS[stage], status, detail }); + return next; + }); + }, []); + + const onStep = useCallback((label: string) => { + setSteps((prev) => [...prev, label]); + }, []); + + const onLine = useCallback((_prefix: string, message: string) => { + setLogs((prev) => [...prev, message]); + }, []); + + const onCbError = useCallback((_stage: HeadlessStage, error: string) => { + setLogs((prev) => [...prev, `ERROR: ${error}`]); + }, []); + + useEffect(() => { + const callbacks: HeadlessCallbacks = { + onStage, + onStep, + onLine, + onError: onCbError, + }; + + runHeadlessFromConfig(driver, config, callbacks).then(onComplete).catch(onError); + }, []); + + // Determine which stages are relevant for this config + const activeStages: HeadlessStage[] = [ + "prereqs", + "provision", + "verify", + ...(config.services?.onePassword ? ["onepassword" as HeadlessStage] : []), + ...(config.network?.tailscale ? ["tailscale" as HeadlessStage] : []), + ...(config.provider ? ["bootstrap" as HeadlessStage] : []), + ]; + + return ( + + + Provisioning: {config.name} + + + + {activeStages.map((stageId) => { + const info = stages.get(stageId); + const status = info?.status ?? "pending"; + const label = STAGE_LABELS[stageId]; + const detail = info?.detail; + + return ( + + {status === "done" ? ( + {"\u2713"} + ) : status === "running" ? ( + + ) : status === "error" ? ( + {"\u2717"} + ) : ( + {"\u25cb"} + )} + + {label} + + {detail && status === "done" && {detail}} + + ); + })} + + + {steps.length > 0 && ( + + {steps.slice(-5).map((step, i) => ( + + {"\u2500"} {step} + + ))} + + )} + + {showLogs && logs.length > 0 && ( + + + {" "} + Log + + + + )} + + + [v] {showLogs ? "hide" : "show"} logs + + + ); +} diff --git a/packages/cli/src/components/sidebar.tsx b/packages/cli/src/components/sidebar.tsx new file mode 100644 index 0000000..c50af36 --- /dev/null +++ b/packages/cli/src/components/sidebar.tsx @@ -0,0 +1,200 @@ +import React from "react"; +import { Text, Box } from "ink"; + +interface SidebarProps { + title: string; + lines: string[]; + width?: number; +} + +export function Sidebar({ title, lines, width = 36 }: SidebarProps) { + return ( + + + {"\u2139"} {title} + + + {lines.map((line, i) => ( + + {line} + + ))} + + ); +} + +export type SidebarContent = { title: string; lines: string[] }; + +/** Contextual help content keyed by focus ID. */ +export const SIDEBAR_HELP: Record = { + name: { + title: "Instance Name", + lines: [ + "Used as the VM name and the", + "directory under project/ for", + "gateway state.", + "", + "Must be a valid hostname:", + "lowercase, hyphens, no spaces.", + "", + "Examples:", + " home-assistant", + " work-agent", + " dev-sandbox", + ], + }, + project: { + title: "Project Directory", + lines: [ + "Host directory for project files,", + "config, and gateway state.", + "", + "Contains data/ (mounted into VM),", + "clawctl.json, and .git.", + "", + "Supports ~ for home directory.", + ], + }, + resources: { + title: "VM Resources", + lines: [ + "CPU, memory, and disk allocation", + "for the Lima VM.", + "", + "Defaults: 4 CPU, 8 GiB, 50 GiB", + "", + "For heavy workloads, consider:", + " 8 CPU, 16 GiB, 100 GiB", + ], + }, + provider: { + title: "Model Provider", + lines: [ + "Required for full bootstrap.", + "Skip to configure later with", + " clawctl oc onboard", + "", + "Provides the LLM backend for", + "your agent. Choose from 14+", + "supported providers.", + ], + }, + "provider.type": { + title: "Provider Type", + lines: [ + "Select your LLM provider.", + "", + "First-class support for:", + " anthropic, openai, gemini,", + " mistral, zai, moonshot,", + " huggingface, and more.", + "", + "Use 'custom' for self-hosted", + "or unsupported providers.", + ], + }, + "provider.apiKey": { + title: "API Key", + lines: [ + "Your provider's API key.", + "Required for all providers", + "except 'custom'.", + "", + "Supports op:// references if", + "1Password is configured.", + "", + "Never stored in clawctl.json", + "(secrets are stripped).", + ], + }, + services: { + title: "Services", + lines: [ + "External service integrations.", + "", + "1Password: inject secrets via", + "op:// references in your config.", + "Requires a service account token.", + ], + }, + "services.opToken": { + title: "1Password Token", + lines: [ + "Service account token for", + "1Password secret injection.", + "", + "Get one at:", + " my.1password.com/developer", + "", + "Enables op:// references in", + "all config fields.", + ], + }, + network: { + title: "Network", + lines: [ + "Gateway port forwarding and", + "Tailscale connectivity.", + "", + "Defaults:", + " Port 18789 forwarded", + " No Tailscale", + ], + }, + "network.tailscale": { + title: "Tailscale Auth Key", + lines: [ + "Pre-authenticated key for", + "Tailscale network join.", + "", + "Generate at:", + " login.tailscale.com/admin", + " /settings/keys", + "", + "Enables remote access to", + "your agent's dashboard.", + ], + }, + bootstrap: { + title: "Agent Identity", + lines: [ + "Give your agent a personality.", + "This becomes the bootstrap", + "prompt sent after setup.", + "", + "Agent context is freeform \u2014", + "describe the creature, vibe,", + "emoji, backstory, whatever", + "makes your agent unique.", + "", + "User info helps the agent", + "personalize interactions.", + ], + }, + telegram: { + title: "Telegram", + lines: [ + "Connect a Telegram bot for", + "chat-based agent control.", + "", + "Requires a bot token from", + "@BotFather on Telegram.", + "", + "allowFrom: Telegram user IDs", + "groups: Group IDs + settings", + ], + }, + review: { + title: "Review", + lines: [ + "Review your configuration", + "before creating the instance.", + "", + "Secrets are validated during", + "provisioning (not here).", + "", + "Config will be saved to", + " /clawctl.json", + ], + }, +}; diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx new file mode 100644 index 0000000..7cb2565 --- /dev/null +++ b/packages/cli/src/steps/config-builder.tsx @@ -0,0 +1,863 @@ +import React, { useState, useMemo } from "react"; +import { Text, Box, useInput } from "ink"; +import TextInput from "ink-text-input"; +import SelectInput from "ink-select-input"; +import { FormField } from "../components/form-field.js"; +import { FormSection } from "../components/form-section.js"; +import { Sidebar, SIDEBAR_HELP } from "../components/sidebar.js"; +import { ConfigReview } from "../components/config-review.js"; +import { instanceConfigSchema, providerSchema, ALL_PROVIDER_TYPES } from "@clawctl/types"; +import type { InstanceConfig } from "@clawctl/types"; + +type Phase = "form" | "review"; + +/** + * Flat list of all navigable items. Sections are headers; fields within + * expanded sections are sub-items. + */ +type FocusId = + | "name" + | "project" + | "resources" + | "resources.cpus" + | "resources.memory" + | "resources.disk" + | "provider" + | "provider.type" + | "provider.apiKey" + | "provider.model" + | "provider.baseUrl" + | "provider.modelId" + | "services" + | "services.opToken" + | "network" + | "network.port" + | "network.tailscaleKey" + | "network.tailscaleMode" + | "bootstrap" + | "bootstrap.agentName" + | "bootstrap.agentContext" + | "bootstrap.userName" + | "bootstrap.userContext" + | "telegram" + | "telegram.botToken" + | "telegram.allowFrom" + | "action"; // "Review & Create" button + +type SectionId = "resources" | "provider" | "services" | "network" | "bootstrap" | "telegram"; + +const SECTIONS: SectionId[] = [ + "resources", + "provider", + "services", + "network", + "bootstrap", + "telegram", +]; + +const SECTION_CHILDREN: Record = { + resources: ["resources.cpus", "resources.memory", "resources.disk"], + provider: [ + "provider.type", + "provider.apiKey", + "provider.model", + "provider.baseUrl", + "provider.modelId", + ], + services: ["services.opToken"], + network: ["network.port", "network.tailscaleKey", "network.tailscaleMode"], + bootstrap: [ + "bootstrap.agentName", + "bootstrap.agentContext", + "bootstrap.userName", + "bootstrap.userContext", + ], + telegram: ["telegram.botToken", "telegram.allowFrom"], +}; + +function buildFocusList(expanded: Set): FocusId[] { + const list: FocusId[] = ["name", "project"]; + for (const section of SECTIONS) { + list.push(section as FocusId); + if (expanded.has(section)) { + list.push(...SECTION_CHILDREN[section]); + } + } + list.push("action"); + return list; +} + +const MEMORY_OPTIONS = ["4GiB", "8GiB", "16GiB", "32GiB"]; +const DISK_OPTIONS = ["30GiB", "50GiB", "100GiB", "200GiB"]; +const TS_MODE_OPTIONS = [ + { label: "serve", value: "serve" }, + { label: "funnel", value: "funnel" }, + { label: "off", value: "off" }, +]; + +interface ConfigBuilderProps { + onComplete: (config: InstanceConfig) => void; + onSaveOnly?: (config: InstanceConfig) => void; +} + +export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { + const [phase, setPhase] = useState("form"); + + // Config state + const [name, setName] = useState(""); + const [project, setProject] = useState(""); + const [cpus, setCpus] = useState("4"); + const [memory, setMemory] = useState("8GiB"); + const [disk, setDisk] = useState("50GiB"); + + // Provider + const [providerType, setProviderType] = useState(""); + const [providerApiKey, setProviderApiKey] = useState(""); + const [providerModel, setProviderModel] = useState(""); + const [providerBaseUrl, setProviderBaseUrl] = useState(""); + const [providerModelId, setProviderModelId] = useState(""); + + // Services + const [opToken, setOpToken] = useState(""); + + // Network + const [gatewayPort, setGatewayPort] = useState("18789"); + const [tailscaleKey, setTailscaleKey] = useState(""); + const [tailscaleMode, setTailscaleMode] = useState("serve"); + + // Bootstrap + const [agentName, setAgentName] = useState(""); + const [agentContext, setAgentContext] = useState(""); + const [userName, setUserName] = useState(""); + const [userContext, setUserContext] = useState(""); + + // Telegram + const [botToken, setBotToken] = useState(""); + const [allowFrom, setAllowFrom] = useState(""); + + // Navigation + const [expanded, setExpanded] = useState>(new Set()); + const [focusIdx, setFocusIdx] = useState(0); + const [editing, setEditing] = useState(false); + const [selectingProviderType, setSelectingProviderType] = useState(false); + const [selectingTsMode, setSelectingTsMode] = useState(false); + const [selectingMemory, setSelectingMemory] = useState(false); + const [selectingDisk, setSelectingDisk] = useState(false); + + const focusList = useMemo(() => buildFocusList(expanded), [expanded]); + const currentFocus = focusList[focusIdx] ?? "name"; + + // Build the InstanceConfig from current state + const buildConfig = (): InstanceConfig => { + const config: InstanceConfig = { + name: name.trim(), + project: project.trim() || `~/agents/${name.trim()}`, + }; + + const cpuNum = parseInt(cpus, 10); + if (cpuNum || memory !== "8GiB" || disk !== "50GiB") { + config.resources = {}; + if (cpuNum && cpuNum !== 4) config.resources.cpus = cpuNum; + if (memory !== "8GiB") config.resources.memory = memory; + if (disk !== "50GiB") config.resources.disk = disk; + } + + if (providerType) { + config.provider = { type: providerType }; + if (providerApiKey) config.provider.apiKey = providerApiKey; + if (providerModel) config.provider.model = providerModel; + if (providerType === "custom") { + if (providerBaseUrl) config.provider.baseUrl = providerBaseUrl; + if (providerModelId) config.provider.modelId = providerModelId; + } + } + + if (opToken) { + config.services = { onePassword: { serviceAccountToken: opToken } }; + } + + const port = parseInt(gatewayPort, 10); + if (tailscaleKey || (port && port !== 18789)) { + config.network = {}; + if (port && port !== 18789) config.network.gatewayPort = port; + if (tailscaleKey) { + config.network.tailscale = { + authKey: tailscaleKey, + mode: tailscaleMode as "off" | "serve" | "funnel", + }; + } + } + + if (agentName) { + config.bootstrap = { + agent: { name: agentName, context: agentContext || undefined }, + ...(userName ? { user: { name: userName, context: userContext || undefined } } : {}), + }; + } + + if (botToken) { + config.telegram = { + botToken, + ...(allowFrom + ? { + allowFrom: allowFrom + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + } + : {}), + }; + } + + return config; + }; + + // Validate the assembled config + const validate = (): { errors: string[]; warnings: string[] } => { + const config = buildConfig(); + const errors: string[] = []; + const warnings: string[] = []; + + if (!config.name) errors.push("Instance name is required"); + if (!config.project) errors.push("Project directory is required"); + + // Validate provider section if partially filled + if (config.provider) { + const provResult = providerSchema.safeParse(config.provider); + if (!provResult.success) { + for (const issue of provResult.error.issues) { + errors.push(`Provider: ${issue.message}`); + } + } + } + + // Full schema validation + if (config.name && config.project) { + const result = instanceConfigSchema.safeParse(config); + if (!result.success) { + for (const issue of result.error.issues) { + const path = issue.path.join("."); + const msg = path ? `${path}: ${issue.message}` : issue.message; + if (!errors.some((e) => e.includes(issue.message))) { + errors.push(msg); + } + } + } + } + + if (!config.provider) { + warnings.push("No provider configured \u2014 run clawctl oc onboard later"); + } + + return { errors, warnings }; + }; + + // Sidebar content based on current focus + const sidebarFocusKey = phase === "review" ? "review" : currentFocus.split(".")[0]; + const sidebarContent = + SIDEBAR_HELP[currentFocus] ?? SIDEBAR_HELP[sidebarFocusKey] ?? SIDEBAR_HELP["name"]; + + // Section status helpers + const sectionStatus = (id: SectionId): "unconfigured" | "configured" | "error" => { + switch (id) { + case "resources": + return "configured"; // always has defaults + case "provider": + return providerType ? "configured" : "unconfigured"; + case "services": + return opToken ? "configured" : "unconfigured"; + case "network": + return tailscaleKey ? "configured" : "unconfigured"; + case "bootstrap": + return agentName ? "configured" : "unconfigured"; + case "telegram": + return botToken ? "configured" : "unconfigured"; + } + }; + + const sectionSummary = (id: SectionId): string => { + switch (id) { + case "resources": + return `${cpus} cpu \u00b7 ${memory} \u00b7 ${disk}`; + case "provider": + return providerType || ""; + case "services": + return opToken ? "1Password" : ""; + case "network": + return tailscaleKey ? `Tailscale (${tailscaleMode})` : "defaults"; + case "bootstrap": + return agentName || ""; + case "telegram": + return botToken ? "configured" : ""; + } + }; + + const toggleSection = (id: SectionId) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const isSelectMode = selectingProviderType || selectingTsMode || selectingMemory || selectingDisk; + + // Handle input + useInput( + (input, key) => { + if (isSelectMode) return; // SelectInput handles its own input + + if (phase === "review") { + if (key.escape) { + setPhase("form"); + return; + } + if (key.return) { + const { errors } = validate(); + if (errors.length === 0) { + onComplete(buildConfig()); + } + return; + } + if (input.toLowerCase() === "s" && onSaveOnly) { + onSaveOnly(buildConfig()); + return; + } + return; + } + + // Form mode + if (editing) { + if (key.return || key.escape) { + setEditing(false); + // Auto-fill project if empty + if (currentFocus === "name" && !project && name) { + setProject(`~/agents/${name.trim()}`); + } + } + return; + } + + if (key.upArrow) { + setFocusIdx((i) => Math.max(0, i - 1)); + } else if (key.downArrow || key.tab) { + setFocusIdx((i) => Math.min(focusList.length - 1, i + 1)); + } else if (key.return) { + if (SECTIONS.includes(currentFocus as SectionId)) { + toggleSection(currentFocus as SectionId); + } else if (currentFocus === "action") { + setPhase("review"); + } else if (currentFocus === "provider.type") { + setSelectingProviderType(true); + } else if (currentFocus === "network.tailscaleMode") { + setSelectingTsMode(true); + } else if (currentFocus === "resources.memory") { + setSelectingMemory(true); + } else if (currentFocus === "resources.disk") { + setSelectingDisk(true); + } else { + setEditing(true); + } + } else if (key.escape) { + // Collapse parent section + for (const section of SECTIONS) { + if (SECTION_CHILDREN[section].includes(currentFocus)) { + toggleSection(section); + // Move focus to the section header + const newList = buildFocusList(new Set([...expanded].filter((s) => s !== section))); + const sectionIdx = newList.indexOf(section as FocusId); + if (sectionIdx >= 0) setFocusIdx(sectionIdx); + break; + } + } + } else if (input.toLowerCase() === "r") { + setPhase("review"); + } + }, + { isActive: !isSelectMode }, + ); + + // Render the review screen + if (phase === "review") { + const { errors, warnings } = validate(); + return ( + + + + + + + + + ); + } + + // Determine field status + const fieldStatus = (id: FocusId) => { + if (currentFocus === id && editing) return "editing" as const; + if (currentFocus === id) return "focused" as const; + return "idle" as const; + }; + + const providerTypeItems = ALL_PROVIDER_TYPES.map((t) => ({ label: t, value: t })); + + return ( + + + + clawctl create + Configure a new OpenClaw instance + + + + {/* Instance fields (always visible) */} + + {currentFocus === "name" && editing ? ( + + + {"\u25b8"}{" "} + + + Name + + + + ) : ( + + )} + + {currentFocus === "project" && editing ? ( + + + {"\u25b8"}{" "} + + + Project + + + + ) : ( + + )} + + + + + {/* Sections */} + + {/* Resources */} + + {currentFocus === "resources.cpus" && editing ? ( + + + CPUs + + + + ) : ( + + )} + {selectingMemory ? ( + + Memory + ({ label: m, value: m }))} + initialIndex={MEMORY_OPTIONS.indexOf(memory)} + onSelect={(item) => { + setMemory(item.value); + setSelectingMemory(false); + }} + /> + + ) : ( + + )} + {selectingDisk ? ( + + Disk + ({ label: d, value: d }))} + initialIndex={DISK_OPTIONS.indexOf(disk)} + onSelect={(item) => { + setDisk(item.value); + setSelectingDisk(false); + }} + /> + + ) : ( + + )} + + + {/* Provider */} + + {selectingProviderType ? ( + + Type + { + setProviderType(item.value); + setSelectingProviderType(false); + }} + /> + + ) : ( + + )} + {currentFocus === "provider.apiKey" && editing ? ( + + + API Key + + + + ) : ( + + )} + {currentFocus === "provider.model" && editing ? ( + + + Model + + + + ) : ( + + )} + {providerType === "custom" && ( + <> + {currentFocus === "provider.baseUrl" && editing ? ( + + + Base URL + + + + ) : ( + + )} + {currentFocus === "provider.modelId" && editing ? ( + + + Model ID + + + + ) : ( + + )} + + )} + + + {/* Services */} + + {currentFocus === "services.opToken" && editing ? ( + + + 1P Token + + + + ) : ( + + )} + + + {/* Network */} + + {currentFocus === "network.port" && editing ? ( + + + Port + + + + ) : ( + + )} + {currentFocus === "network.tailscaleKey" && editing ? ( + + + TS Auth Key + + + + ) : ( + + )} + {selectingTsMode ? ( + + TS Mode + o.value === tailscaleMode)} + onSelect={(item) => { + setTailscaleMode(item.value); + setSelectingTsMode(false); + }} + /> + + ) : ( + + )} + + + {/* Bootstrap / Agent Identity */} + + {currentFocus === "bootstrap.agentName" && editing ? ( + + + Agent Name + + + + ) : ( + + )} + {currentFocus === "bootstrap.agentContext" && editing ? ( + + + Agent Vibe + + + + ) : ( + + )} + {currentFocus === "bootstrap.userName" && editing ? ( + + + Your Name + + + + ) : ( + + )} + {currentFocus === "bootstrap.userContext" && editing ? ( + + + Your Context + + + + ) : ( + + )} + + + {/* Telegram */} + + {currentFocus === "telegram.botToken" && editing ? ( + + + Bot Token + + + + ) : ( + + )} + {currentFocus === "telegram.allowFrom" && editing ? ( + + + Allow From + + + + ) : ( + + )} + + + + + + {/* Action button */} + + + {currentFocus === "action" ? "\u25b8 " : " "} + [Enter] Review & Create + + + + + {/* Keybinding hints */} + + + [{"\u2191\u2193"}] navigate {"\u00b7"} [Enter] {editing ? "confirm" : "edit/expand"}{" "} + {"\u00b7"} [Esc] {editing ? "cancel" : "collapse"} {"\u00b7"} [R] review + + + + + {/* Sidebar */} + + + + + ); +} diff --git a/packages/cli/src/steps/configure.tsx b/packages/cli/src/steps/configure.tsx deleted file mode 100644 index 38b0266..0000000 --- a/packages/cli/src/steps/configure.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useState } from "react"; -import { Text, Box, useInput } from "ink"; -import TextInput from "ink-text-input"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { VMConfig, MountSpec } from "@clawctl/types"; -import os from "os"; -import { join } from "path"; - -interface ConfigureProps { - onComplete: (config: VMConfig) => void; -} - -interface Field { - key: keyof VMConfig; - label: string; - defaultValue: string; -} - -const FIELDS: Field[] = [ - { - key: "projectDir", - label: "Project directory", - defaultValue: join(os.homedir(), "openclaw-vms/my-agent"), - }, - { key: "vmName", label: "VM name", defaultValue: "openclaw" }, - { key: "cpus", label: "CPUs", defaultValue: "4" }, - { key: "memory", label: "Memory (GiB)", defaultValue: "8GiB" }, - { key: "disk", label: "Disk (GiB)", defaultValue: "50GiB" }, -]; - -type Phase = "fields" | "mount-home"; - -export function Configure({ onComplete }: ConfigureProps) { - const [phase, setPhase] = useState("fields"); - const [fieldIndex, setFieldIndex] = useState(0); - const [values, setValues] = useState>({}); - const [currentValue, setCurrentValue] = useState(FIELDS[0].defaultValue); - const [mountHome, setMountHome] = useState(undefined); - - const currentField = FIELDS[fieldIndex]; - - function buildConfig(newValues: Record, extraMounts?: MountSpec[]): VMConfig { - const config: VMConfig = { - projectDir: newValues.projectDir!.replace(/^~/, os.homedir()), - vmName: newValues.vmName!, - cpus: parseInt(newValues.cpus!, 10), - memory: newValues.memory!, - disk: newValues.disk!, - }; - if (extraMounts) config.extraMounts = extraMounts; - return config; - } - - function handleSubmit(value: string) { - const finalValue = value || currentField.defaultValue; - const newValues = { ...values, [currentField.key]: finalValue }; - setValues(newValues); - - if (fieldIndex < FIELDS.length - 1) { - const nextIndex = fieldIndex + 1; - setFieldIndex(nextIndex); - setCurrentValue(FIELDS[nextIndex].defaultValue); - } else { - setPhase("mount-home"); - } - } - - useInput((input, key) => { - if (phase !== "mount-home") return; - - if (input.toLowerCase() === "y") { - setMountHome(true); - onComplete(buildConfig(values, [{ location: "~", mountPoint: "/mnt/host" }])); - } else if (input.toLowerCase() === "n" || key.return) { - setMountHome(false); - onComplete(buildConfig(values)); - } - }); - - return ( - - - - - {/* Show already-answered fields */} - {FIELDS.slice(0, fieldIndex).map((field) => ( - - {field.label}: {values[field.key]} - - ))} - - {/* Current field (only during fields phase) */} - {phase === "fields" && ( - - - ? {currentField.label}:{" "} - - - - )} - - {/* Show all fields as completed when in mount-home phase */} - {phase === "mount-home" && ( - <> - - {FIELDS[FIELDS.length - 1].label}:{" "} - {values[FIELDS[FIELDS.length - 1].key]} - - - {mountHome === undefined && ( - - ? Mount home directory in VM? (read-only){" "} - [y/N] - - )} - {mountHome === true && ( - - Home directory will be mounted at /mnt/host - - )} - {mountHome === false && ○ Home directory not mounted} - - )} - - - ); -} diff --git a/packages/cli/src/steps/create-vm.tsx b/packages/cli/src/steps/create-vm.tsx deleted file mode 100644 index a7049e3..0000000 --- a/packages/cli/src/steps/create-vm.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { ProcessOutput } from "../components/process-output.js"; -import { Spinner } from "../components/spinner.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver, ProvisionFeatures } from "@clawctl/host-core"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import { provisionVM } from "@clawctl/host-core"; - -interface CreateVMProps { - driver: VMDriver; - config: VMConfig; - provisionFeatures: ProvisionFeatures; - onComplete: () => void; -} - -type Phase = "project-setup" | "generating" | "creating-vm" | "provisioning" | "done" | "error"; - -export function CreateVM({ driver, config, provisionFeatures, onComplete }: CreateVMProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine: addProcessLog } = useProcessLogs(); - const [phase, setPhase] = useState("project-setup"); - const [error, setError] = useState(); - const [completedSteps, setCompletedSteps] = useState([]); - - useEffect(() => { - async function run() { - try { - await provisionVM( - driver, - config, - { - onPhase: (p) => setPhase(p as Phase), - onStep: (step) => setCompletedSteps((prev) => [...prev, step]), - onLine: addProcessLog, - }, - { extraMounts: config.extraMounts }, - undefined, - provisionFeatures, - ); - - setTimeout(() => onComplete(), 500); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setPhase("error"); - } - } - run(); - }, []); - - return ( - - - - - {completedSteps.map((step, i) => ( - - {step} - - ))} - - {phase === "project-setup" && } - {phase === "generating" && } - {phase === "creating-vm" && ( - - )} - {phase === "provisioning" && ( - - )} - {phase === "error" && ✗ Error: {error}} - - - ); -} diff --git a/packages/cli/src/steps/credential-setup.tsx b/packages/cli/src/steps/credential-setup.tsx deleted file mode 100644 index f694ef8..0000000 --- a/packages/cli/src/steps/credential-setup.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { ProcessOutput } from "../components/process-output.js"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver } from "@clawctl/host-core"; -import { setupOnePassword, connectTailscaleInteractive } from "@clawctl/host-core"; -import type { CredentialConfig } from "../types.js"; - -interface CredentialSetupProps { - driver: VMDriver; - config: VMConfig; - credentialConfig: CredentialConfig; - onComplete: (creds: CredentialConfig) => void; -} - -type Phase = "setting-up" | "done"; - -export function CredentialSetup({ - driver, - config, - credentialConfig, - onComplete, -}: CredentialSetupProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine: addLog } = useProcessLogs(); - const [phase, setPhase] = useState("setting-up"); - const [error, setError] = useState(); - const [completedSteps, setCompletedSteps] = useState([]); - - const hasOp = !!credentialConfig.opToken; - const hasTs = !!credentialConfig.tailscaleAuthKey; - - useEffect(() => { - async function run() { - // Validate 1Password token in VM - if (hasOp) { - try { - const result = await setupOnePassword( - driver, - config.vmName, - credentialConfig.opToken!, - addLog, - ); - if (result.valid) { - setCompletedSteps((prev) => [ - ...prev, - `1Password validated (${result.account || "ok"})`, - ]); - } else { - setError(`1Password: ${result.error || "validation failed"}`); - } - } catch (err) { - setError(`1Password: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // Connect Tailscale in VM - if (hasTs) { - try { - const result = await connectTailscaleInteractive(driver, config.vmName, addLog); - if (result.connected) { - setCompletedSteps((prev) => [ - ...prev, - `Tailscale connected as ${result.hostname || "ok"}`, - ]); - } else { - setError(`Tailscale: connection failed`); - } - } catch (err) { - setError(`Tailscale: ${err instanceof Error ? err.message : String(err)}`); - } - } - - if (!hasOp && !hasTs) { - setCompletedSteps(["No credentials to set up"]); - } - - setPhase("done"); - setTimeout(() => onComplete(credentialConfig), 500); - } - run(); - }, []); - - return ( - - - - - {completedSteps.map((step, i) => ( - - {step} - - ))} - - {phase === "setting-up" && ( - - )} - - {error && {error}} - - - ); -} diff --git a/packages/cli/src/steps/credentials.tsx b/packages/cli/src/steps/credentials.tsx deleted file mode 100644 index 1f901b0..0000000 --- a/packages/cli/src/steps/credentials.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import React, { useState } from "react"; -import { Text, Box, useInput } from "ink"; -import TextInput from "ink-text-input"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { CredentialConfig } from "../types.js"; - -interface CredentialsProps { - onComplete: (creds: CredentialConfig) => void; -} - -type Phase = "ask-op" | "op-token" | "ask-tailscale" | "ask-tailscale-mode" | "done"; - -export function Credentials({ onComplete }: CredentialsProps) { - const [phase, setPhase] = useState("ask-op"); - const [opToken, setOpToken] = useState(""); - const [skipOp, setSkipOp] = useState(false); - const [skipTs, setSkipTs] = useState(false); - const [tsMode, setTsMode] = useState<"serve" | "off">("serve"); - const [wantTs, setWantTs] = useState(false); - - useInput((input, key) => { - if (phase === "ask-op") { - if (input.toLowerCase() === "y" || key.return) { - setPhase("op-token"); - } else if (input.toLowerCase() === "n") { - setSkipOp(true); - setPhase("ask-tailscale"); - } - } - if (phase === "ask-tailscale") { - if (input.toLowerCase() === "y" || key.return) { - setWantTs(true); - setPhase("ask-tailscale-mode"); - } else if (input.toLowerCase() === "n") { - setSkipTs(true); - finishWithCreds(false); - } - } - if (phase === "ask-tailscale-mode") { - if (input === "1" || key.return) { - setTsMode("serve"); - finishWithCreds(true, "serve"); - } else if (input === "2") { - setTsMode("off"); - finishWithCreds(true, "off"); - } - } - }); - - function finishWithCreds(withTs: boolean = false, mode?: "serve" | "off") { - setPhase("done"); - const creds: CredentialConfig = {}; - if (opToken) creds.opToken = opToken; - if (withTs) { - creds.tailscaleAuthKey = "interactive"; - creds.tailscaleMode = mode; - } - setTimeout(() => onComplete(creds), 300); - } - - return ( - - - - - {/* 1Password section */} - {phase === "ask-op" && ( - - ? Set up 1Password? [Y/n] - - )} - {phase === "op-token" && ( - - - ? OP_SERVICE_ACCOUNT_TOKEN:{" "} - - { - setOpToken(val); - setPhase("ask-tailscale"); - }} - mask="*" - /> - - )} - {opToken && phase !== "ask-op" && phase !== "op-token" && ( - - 1Password token collected (will validate after VM creation) - - )} - {skipOp && ○ 1Password setup skipped (can configure later)} - - {/* Tailscale section */} - {phase === "ask-tailscale" && ( - <> - - - ? Set up Tailscale? [Y/n] - - - )} - {phase === "ask-tailscale-mode" && ( - - - ? Tailscale gateway mode: - - - {" "} - [1] Serve — HTTPS on your tailnet, tokenless dashboard{" "} - (recommended) - - - {" "} - [2] Off — raw Tailscale IP only, no HTTPS proxy - - - )} - {phase === "done" && wantTs && ( - - Tailscale: {tsMode} mode (will connect after VM creation) - - )} - {skipTs && ○ Tailscale setup skipped (can configure later)} - - - ); -} diff --git a/packages/cli/src/steps/finish.tsx b/packages/cli/src/steps/finish.tsx deleted file mode 100644 index a7fe9a4..0000000 --- a/packages/cli/src/steps/finish.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import { Text, Box, useApp } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { VMConfig } from "@clawctl/types"; -import { GATEWAY_PORT } from "@clawctl/types"; -import { BIN_NAME } from "@clawctl/host-core"; - -export interface FinishResult { - action: "finish"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -interface FinishProps { - config: VMConfig; - onboardSkipped?: boolean; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -export function Finish({ config, onboardSkipped, tailscaleMode }: FinishProps) { - const { exit } = useApp(); - const exited = useRef(false); - - useEffect(() => { - if (!exited.current) { - exited.current = true; - exit({ - action: "finish", - vmName: config.vmName, - projectDir: config.projectDir, - tailscaleMode, - } as FinishResult); - } - }, []); - return ( - - - - - - {onboardSkipped - ? "Your OpenClaw gateway is ready. Onboarding was skipped." - : "Your OpenClaw gateway is ready!"} - - - - - {onboardSkipped ? ( - <> - # Run OpenClaw onboarding - {BIN_NAME} oc onboard --install-daemon - - - ) : ( - <> - # Access dashboard - http://localhost:{GATEWAY_PORT} - - - )} - - # Enter the VM - {BIN_NAME} shell - - - # Enable tab completions: {BIN_NAME} completions --help - - - ); -} diff --git a/packages/cli/src/steps/host-setup.tsx b/packages/cli/src/steps/host-setup.tsx deleted file mode 100644 index 3e094e1..0000000 --- a/packages/cli/src/steps/host-setup.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { ProcessOutput } from "../components/process-output.js"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import type { VMDriver } from "@clawctl/host-core"; -import type { PrereqStatus } from "../types.js"; - -interface HostSetupProps { - driver: VMDriver; - prereqs: PrereqStatus; - onComplete: (updatedPrereqs: PrereqStatus) => void; -} - -export function HostSetup({ driver, prereqs, onComplete }: HostSetupProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine } = useProcessLogs(); - const [status, setStatus] = useState<"installing" | "done" | "error">( - prereqs.hasVMBackend ? "done" : "installing", - ); - const [backendVersion, setBackendVersion] = useState(prereqs.vmBackendVersion); - const [error, setError] = useState(); - - useEffect(() => { - if (prereqs.hasVMBackend) { - setTimeout(() => onComplete({ ...prereqs }), 300); - return; - } - - async function setup() { - try { - const version = await driver.install(addLine); - setBackendVersion(version); - setStatus("done"); - setTimeout( - () => - onComplete({ - ...prereqs, - hasVMBackend: true, - vmBackendVersion: version, - }), - 500, - ); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStatus("error"); - } - } - setup(); - }, []); - - return ( - - - - - {status === "installing" && ( - - )} - {status === "done" && ( - - Lima {backendVersion} installed - - )} - {status === "error" && ✗ Failed to install Lima: {error}} - - - ); -} diff --git a/packages/cli/src/steps/onboard.tsx b/packages/cli/src/steps/onboard.tsx deleted file mode 100644 index ce433f8..0000000 --- a/packages/cli/src/steps/onboard.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useState } from "react"; -import { Text, Box, useApp, useInput } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { VMConfig } from "@clawctl/types"; - -interface OnboardProps { - config: VMConfig; - tailscaleMode?: "off" | "serve" | "funnel"; - onComplete: (skipped: boolean) => void; -} - -export interface OnboardResult { - action: "onboard"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -export function Onboard({ config, tailscaleMode, onComplete }: OnboardProps) { - const { exit } = useApp(); - const [launched, setLaunched] = useState(false); - - useInput((input, key) => { - if (launched) return; - if (key.return) { - setLaunched(true); - exit({ - action: "onboard", - vmName: config.vmName, - projectDir: config.projectDir, - tailscaleMode, - } as OnboardResult); - } else if (input.toLowerCase() === "s") { - onComplete(true); - } - }); - - return ( - - - - - OpenClaw's onboarding wizard will configure your instance. - - It runs interactively inside the VM — you'll answer a few questions about your provider, - API keys, and preferences. - - - - [Enter] Start onboarding{" "} - [s] Skip (do it later) - - - - ); -} diff --git a/packages/cli/src/steps/prereq-check.tsx b/packages/cli/src/steps/prereq-check.tsx new file mode 100644 index 0000000..02319ad --- /dev/null +++ b/packages/cli/src/steps/prereq-check.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect } from "react"; +import { Text, Box } from "ink"; +import { Spinner } from "../components/spinner.js"; +import { ProcessOutput } from "../components/process-output.js"; +import { useVerbose } from "../hooks/verbose-context.js"; +import { useProcessLogs } from "../hooks/use-process-logs.js"; +import { checkPrereqs } from "@clawctl/host-core"; +import type { VMDriver } from "@clawctl/host-core"; + +type Phase = "checking" | "installing" | "done" | "error"; + +interface PrereqCheckProps { + driver: VMDriver; + onComplete: () => void; +} + +export function PrereqCheck({ driver, onComplete }: PrereqCheckProps) { + const verbose = useVerbose(); + const { lines: installLogs, addLine } = useProcessLogs(); + const [phase, setPhase] = useState("checking"); + const [isMacOS, setIsMacOS] = useState(false); + const [isArm64, setIsArm64] = useState(false); + const [hasHomebrew, setHasHomebrew] = useState(false); + const [hasVMBackend, setHasVMBackend] = useState(false); + const [vmVersion, setVmVersion] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + async function run() { + const prereqs = await checkPrereqs(driver); + setIsMacOS(prereqs.isMacOS); + setIsArm64(prereqs.isArm64); + setHasHomebrew(prereqs.hasHomebrew); + setHasVMBackend(prereqs.hasVMBackend); + setVmVersion(prereqs.vmBackendVersion); + + if (!prereqs.isMacOS || !prereqs.hasHomebrew) { + setError(!prereqs.isMacOS ? "macOS is required" : "Homebrew is required (https://brew.sh)"); + setPhase("error"); + return; + } + + if (!prereqs.hasVMBackend) { + setPhase("installing"); + try { + const version = await driver.install(addLine); + setHasVMBackend(true); + setVmVersion(version); + setPhase("done"); + setTimeout(() => onComplete(), 500); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setPhase("error"); + } + return; + } + + setPhase("done"); + setTimeout(() => onComplete(), 500); + } + run(); + }, []); + + const Check = ({ ok, label }: { ok: boolean; label: string }) => ( + + {ok ? "\u2713" : "\u2717"} {label} + + ); + + return ( + + + + clawctl + Set up an OpenClaw VM in minutes + + + + {phase === "checking" && } + + {phase !== "checking" && ( + + + + + + )} + + {phase === "installing" && ( + + + + )} + + {phase === "error" && ( + + + {"\u2717"} {error} + + + )} + + ); +} diff --git a/packages/cli/src/steps/provision-status.tsx b/packages/cli/src/steps/provision-status.tsx deleted file mode 100644 index 7b5d1b0..0000000 --- a/packages/cli/src/steps/provision-status.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { LogOutput } from "../components/log-output.js"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver } from "@clawctl/host-core"; -import type { ProvisioningStep } from "../types.js"; -import { verifyProvisioning } from "@clawctl/host-core"; - -interface ProvisionStatusProps { - driver: VMDriver; - config: VMConfig; - onComplete: () => void; -} - -export function ProvisionStatus({ driver, config, onComplete }: ProvisionStatusProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine: addLog } = useProcessLogs(); - const [steps, setSteps] = useState([ - { label: "Node.js 22", status: "pending" }, - { label: "Tailscale", status: "pending" }, - { label: "Homebrew", status: "pending" }, - { label: "1Password CLI", status: "pending" }, - { label: "OpenClaw", status: "pending" }, - ]); - const [error, setError] = useState(); - - useEffect(() => { - async function verify() { - try { - // Mark all as running - setSteps((prev) => prev.map((s) => ({ ...s, status: "running" as const }))); - - const results = await verifyProvisioning(driver, config.vmName, addLog); - - const newSteps: ProvisioningStep[] = results.map((r) => ({ - label: r.label, - status: r.passed ? ("done" as const) : ("error" as const), - error: r.error, - })); - setSteps(newSteps); - - const allPassed = results.every((r) => r.passed); - if (allPassed) { - setTimeout(() => onComplete(), 500); - } else { - setError("Some tools failed to install. Re-run provisioning or check VM logs."); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - } - verify(); - }, []); - - const StatusIcon = ({ s }: { s: ProvisioningStep }) => { - switch (s.status) { - case "done": - return ; - case "error": - return ; - case "running": - return ; - default: - return ; - } - }; - - return ( - - - - - {steps.map((step, i) => ( - - - - {" "} - {step.label} - {step.status === "done" && installed} - {step.error && — {step.error}} - - - ))} - {verbose && processLogs.length > 0 && } - {error && Error: {error}} - - - ); -} diff --git a/packages/cli/src/steps/welcome.tsx b/packages/cli/src/steps/welcome.tsx deleted file mode 100644 index 2dba9e6..0000000 --- a/packages/cli/src/steps/welcome.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { Spinner } from "../components/spinner.js"; -import type { VMDriver } from "@clawctl/host-core"; -import type { PrereqStatus } from "../types.js"; -import { checkPrereqs } from "@clawctl/host-core"; - -interface WelcomeProps { - driver: VMDriver; - onComplete: (prereqs: PrereqStatus) => void; -} - -export function Welcome({ driver, onComplete }: WelcomeProps) { - const [checking, setChecking] = useState(true); - const [prereqs, setPrereqs] = useState({ - isMacOS: false, - isArm64: false, - hasHomebrew: false, - hasVMBackend: false, - }); - - useEffect(() => { - async function check() { - const result = await checkPrereqs(driver); - setPrereqs(result); - setChecking(false); - - // Auto-advance after a brief pause - setTimeout(() => onComplete(result), 500); - } - check(); - }, []); - - const Check = ({ ok, label }: { ok: boolean; label: string }) => ( - - {ok ? "✓" : "✗"} {label} - - ); - - return ( - - - - clawctl - Set up an OpenClaw VM in minutes - - - - - - {checking ? ( - - ) : ( - - - - - {!prereqs.hasVMBackend && ( - - → Will install Lima via Homebrew - - )} - - )} - - ); -} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index dff7f57..143cc0d 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -11,20 +11,3 @@ export interface ProvisioningStep { status: "pending" | "running" | "done" | "error"; error?: string; } - -export interface CredentialConfig { - opToken?: string; - tailscaleAuthKey?: string; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -export type WizardStep = - | "welcome" - | "configure" - | "credentials" - | "host-setup" - | "create-vm" - | "provision" - | "credential-setup" - | "onboard" - | "finish"; diff --git a/packages/host-core/src/headless.ts b/packages/host-core/src/headless.ts index b620e61..2d04ca0 100644 --- a/packages/host-core/src/headless.ts +++ b/packages/host-core/src/headless.ts @@ -11,6 +11,7 @@ import { syncSecretsToVM, writeEnvSecrets } from "./secrets-sync.js"; import { bootstrapOpenclaw } from "./bootstrap.js"; import { cleanupVM, onSignalCleanup } from "./cleanup.js"; import { GATEWAY_PORT } from "@clawctl/types"; +import type { InstanceConfig } from "@clawctl/types"; import { BIN_NAME } from "./bin-name.js"; import type { VMDriver } from "./drivers/types.js"; @@ -26,20 +27,60 @@ export interface HeadlessResult { tailscaleUrl?: string; } -function log(prefix: string, message: string) { - console.log(`[${prefix}] ${message}`); +export type HeadlessStage = + | "prereqs" + | "provision" + | "verify" + | "onepassword" + | "secrets" + | "tailscale" + | "bootstrap" + | "done"; + +export type StageStatus = "pending" | "running" | "done" | "error"; + +export interface HeadlessCallbacks { + onStage?: (stage: HeadlessStage, status: StageStatus, detail?: string) => void; + onStep?: (label: string) => void; + onLine?: (prefix: string, message: string) => void; + onError?: (stage: HeadlessStage, error: string) => void; +} + +function defaultCallbacks(): Required { + return { + onStage: (stage, status, detail) => { + if (status === "running") console.log(`[${stage}] ${detail ?? "Starting..."}`); + else if (status === "done") console.log(`[${stage}] ${detail ?? "Done"}`); + }, + onStep: (label) => console.log(` ✓ ${label}`), + onLine: (prefix, message) => console.log(`[${prefix}] ${message}`), + onError: (stage, error) => console.log(`[${stage}] ✗ ${error}`), + }; } /** Run headless VM creation from a config file. */ export async function runHeadless(driver: VMDriver, configPath: string): Promise { - // 1. Load config - log("config", `Loading ${configPath}`); - let config = await loadConfig(configPath); - const vmConfig = configToVMConfig(config); - log("config", `Instance: ${config.name} → ${config.project}`); + const cb = defaultCallbacks(); + cb.onLine("config", `Loading ${configPath}`); + const config = await loadConfig(configPath); + cb.onLine("config", `Instance: ${config.name} → ${config.project}`); + return runHeadlessFromConfig(driver, config); +} - // 2. Check prerequisites - log("prereqs", "Checking host prerequisites..."); +/** Run headless VM creation from an in-memory InstanceConfig. */ +export async function runHeadlessFromConfig( + driver: VMDriver, + inputConfig: InstanceConfig, + callbacks?: HeadlessCallbacks, +): Promise { + let config = inputConfig; + const cb: Required = { + ...defaultCallbacks(), + ...callbacks, + }; + + // 1. Check prerequisites + cb.onStage("prereqs", "running", "Checking host prerequisites..."); const prereqs = await checkPrereqs(driver); if (!prereqs.isMacOS) throw new Error("macOS is required"); @@ -47,16 +88,18 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise if (!prereqs.hasHomebrew) throw new Error("Homebrew is required (https://brew.sh)"); if (!prereqs.hasVMBackend) { - log("prereqs", "Lima not found, installing via Homebrew..."); - const version = await driver.install((line: string) => log("brew", line)); - log("prereqs", `Lima ${version} installed`); + cb.onLine("prereqs", "Lima not found, installing via Homebrew..."); + const version = await driver.install((line: string) => cb.onLine("brew", line)); + cb.onLine("prereqs", `Lima ${version} installed`); } else { - log("prereqs", `Lima ${prereqs.vmBackendVersion} found`); + cb.onLine("prereqs", `Lima ${prereqs.vmBackendVersion} found`); } + cb.onStage("prereqs", "done", `Lima ${prereqs.vmBackendVersion ?? "installed"}`); + + const vmConfig = configToVMConfig(config); - // Steps 3–7 create resources that need cleanup on failure. - // Register signal handlers so Ctrl+C also triggers cleanup. - const cleanupLog = (msg: string) => log("cleanup", msg); + // Signal cleanup for steps 2–7 + const cleanupLog = (msg: string) => cb.onLine("cleanup", msg); const removeSignalHandlers = onSignalCleanup( driver, () => ({ vmName: vmConfig.vmName, projectDir: vmConfig.projectDir }), @@ -64,8 +107,8 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise ); try { - // 3. Provision VM - log("provision", "Starting VM provisioning..."); + // 2. Provision VM + cb.onStage("provision", "running", "Starting VM provisioning..."); const provisionFeatures = { onePassword: !!config.services?.onePassword || !!config.capabilities?.["one-password"], tailscale: !!config.network?.tailscale || !!config.capabilities?.tailscale, @@ -74,9 +117,9 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise driver, vmConfig, { - onPhase: (phase: string) => log("provision", `Phase: ${phase}`), - onStep: (step: string) => log("provision", `✓ ${step}`), - onLine: (line: string) => log("vm", line), + onPhase: (phase: string) => cb.onLine("provision", `Phase: ${phase}`), + onStep: (step: string) => cb.onStep(step), + onLine: (line: string) => cb.onLine("vm", line), }, { forwardGateway: config.network?.forwardGateway ?? true, @@ -86,9 +129,10 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise undefined, provisionFeatures, ); + cb.onStage("provision", "done", "VM provisioned"); - // 4. Verify provisioning - log("verify", "Verifying installed tools..."); + // 3. Verify provisioning + cb.onStage("verify", "running", "Verifying installed tools..."); const results = await verifyProvisioning( driver, vmConfig.vmName, @@ -98,42 +142,44 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise const errors: string[] = []; for (const r of results) { if (r.passed) { - log("verify", `✓ ${r.label}`); + cb.onStep(`${r.label}`); } else if (r.warn) { const reason = r.availableAfter ? `expected after ${r.availableAfter}` : "warning"; - log("verify", `⚠ ${r.label}: ${r.error} (${reason})`); + cb.onLine("verify", `⚠ ${r.label}: ${r.error} (${reason})`); } else { - log("verify", `✗ ${r.label}: ${r.error}`); + cb.onError("verify", `${r.label}: ${r.error}`); errors.push(r.label); } } if (errors.length > 0) { + cb.onStage("verify", "error", "Some tools failed to install"); throw new Error("Some tools failed to install — check logs above"); } + cb.onStage("verify", "done", "All tools verified"); - // 5. Services: 1Password + // 4. Services: 1Password if (config.services?.onePassword) { - log("1password", "Setting up 1Password..."); + cb.onStage("onepassword", "running", "Setting up 1Password..."); const opResult = await setupOnePassword( driver, vmConfig.vmName, config.services.onePassword.serviceAccountToken, - (line: string) => log("1password", line), + (line: string) => cb.onLine("1password", line), ); if (opResult.valid) { - log("1password", `✓ Token validated (${opResult.account})`); + cb.onStage("onepassword", "done", `Token validated (${opResult.account})`); } else { - log("1password", `✗ ${opResult.error}`); + cb.onStage("onepassword", "error", opResult.error); + cb.onError("onepassword", opResult.error ?? "Token validation failed"); throw new Error("1Password setup failed"); } } - // 5.5. Resolve op:// secret references in the VM + // 4.5. Resolve op:// secret references let resolvedMap: ResolvedSecretRef[] | undefined; if (hasOpRefs(config as unknown as Record)) { - log("secrets", "Resolving op:// secret references..."); + cb.onStage("secrets", "running", "Resolving op:// secret references..."); - // Capture original op:// refs before resolution const opRefs = findSecretRefs(config as unknown as Record).filter( (r) => r.scheme === "op", ); @@ -142,78 +188,71 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise driver, vmConfig.vmName, config as unknown as Record, - (line: string) => log("secrets", line), + (line: string) => cb.onLine("secrets", line), ); - // Build resolved map pairing each ref with its resolved value resolvedMap = opRefs.map((ref) => ({ ...ref, resolvedValue: getNestedValue(resolved, ref.path) as string, })); config = resolved as unknown as typeof config; - log("secrets", "All references resolved"); + cb.onStage("secrets", "done", "All references resolved"); - // Sync infrastructure secrets to VM and host await syncSecretsToVM(driver, vmConfig.vmName, resolvedMap, (line: string) => - log("secrets", line), + cb.onLine("secrets", line), + ); + await writeEnvSecrets(config.project, resolvedMap, (line: string) => + cb.onLine("secrets", line), ); - await writeEnvSecrets(config.project, resolvedMap, (line: string) => log("secrets", line)); } - // 6. Network: Tailscale + // 5. Network: Tailscale if (config.network?.tailscale) { - log("tailscale", "Connecting to Tailscale..."); + cb.onStage("tailscale", "running", "Connecting to Tailscale..."); const tsResult = await setupTailscale( driver, vmConfig.vmName, config.network.tailscale.authKey, - (line: string) => log("tailscale", line), + (line: string) => cb.onLine("tailscale", line), ); if (tsResult.connected) { - log("tailscale", `✓ Connected as ${tsResult.hostname}`); + cb.onStage("tailscale", "done", `Connected as ${tsResult.hostname}`); } else { - log("tailscale", `✗ ${tsResult.error}`); + cb.onStage("tailscale", "error", tsResult.error); + cb.onError("tailscale", tsResult.error ?? "Connection failed"); throw new Error("Tailscale connection failed"); } } - // 7. Bootstrap openclaw (if provider configured) + // 6. Bootstrap openclaw (if provider configured) const hostPort = config.network?.gatewayPort ?? GATEWAY_PORT; let gatewayToken: string | undefined; let dashboardUrl: string | undefined; let tailscaleUrl: string | undefined; if (config.provider) { - log("bootstrap", "Running openclaw onboard..."); + cb.onStage("bootstrap", "running", "Running openclaw onboard..."); const result = await bootstrapOpenclaw( driver, vmConfig.vmName, config, - (line: string) => log("bootstrap", line), + (line: string) => cb.onLine("bootstrap", line), resolvedMap, ); gatewayToken = result.gatewayToken; dashboardUrl = result.dashboardUrl; tailscaleUrl = result.tailscaleUrl; - log("bootstrap", `Gateway token: ${result.gatewayToken}`); if (!result.doctorPassed) { - log("bootstrap", "Warning: openclaw doctor reported issues"); - } - log("done", `Instance "${config.name}" is ready`); - log("done", `Dashboard: ${result.dashboardUrl}/#token=${result.gatewayToken}`); - if (tailscaleUrl) { - log("done", `Tailscale: ${tailscaleUrl}`); - log("done", "First tailnet connection requires device approval:"); - log("done", ` ${BIN_NAME} oc devices list`); - log("done", ` ${BIN_NAME} oc devices approve `); + cb.onLine("bootstrap", "Warning: openclaw doctor reported issues"); } + cb.onStage("bootstrap", "done", "OpenClaw bootstrapped"); } else { - log("done", `Instance "${config.name}" is ready`); - log("done", `Enter VM: ${BIN_NAME} shell`); - log("done", `No provider configured — run '${BIN_NAME} oc onboard' when ready`); + cb.onLine("done", `No provider configured — run '${BIN_NAME} oc onboard' when ready`); } + cb.onStage("done", "done", `Instance "${config.name}" is ready`); + // Write clawctl.json (sanitized config) to project dir const sanitized = sanitizeConfig(config); await writeFile( @@ -233,7 +272,7 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise tailscaleUrl, }; } catch (err) { - log("error", "Provisioning failed, cleaning up..."); + cb.onLine("error", "Provisioning failed, cleaning up..."); await cleanupVM(driver, vmConfig.vmName, vmConfig.projectDir, cleanupLog); throw err; } finally { diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index 6a1865c..f369722 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -45,7 +45,7 @@ export { checkPrereqs } from "./prereqs.js"; export type { PrereqStatus } from "./prereqs.js"; // Credentials -export { setupOnePassword, setupTailscale, connectTailscaleInteractive } from "./credentials.js"; +export { setupOnePassword, setupTailscale } from "./credentials.js"; export type { OpResult, TailscaleResult } from "./credentials.js"; // Secrets sync @@ -107,5 +107,5 @@ export { cleanupVM, onSignalCleanup } from "./cleanup.js"; export type { CleanupTarget } from "./cleanup.js"; // Headless -export { runHeadless } from "./headless.js"; -export type { HeadlessResult } from "./headless.js"; +export { runHeadless, runHeadlessFromConfig } from "./headless.js"; +export type { HeadlessResult, HeadlessCallbacks, HeadlessStage, StageStatus } from "./headless.js"; diff --git a/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md b/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md index 1b50a9e..da42bf9 100644 --- a/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md +++ b/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md @@ -7,6 +7,7 @@ Replace the 9-step interactive wizard with a config-building TUI that produces an `InstanceConfig` and delegates to the existing headless pipeline. This unifies two code paths into one, covers the full schema, and provides a better UX. **In scope:** + - Two-pane TUI (form + contextual sidebar) - Collapsible sections for all InstanceConfig fields - Inline Zod validation per field/section @@ -16,6 +17,7 @@ Replace the 9-step interactive wizard with a config-building TUI that produces a - Cleanup of old wizard steps **Out of scope:** + - Changing headless pipeline logic - New capabilities - Interactive Tailscale login (require auth key) From a8734a830653afa3ff9c6c1ad316a5467ee340a0 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 10:58:03 +0100 Subject: [PATCH 03/13] chore: add task files for TUI rendering bugs and fullscreen layout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TASK.md | 64 ++++++++++++++++++ .../TASK.md | 67 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 tasks/2026-03-17_0921_tui-rendering-bugs/TASK.md create mode 100644 tasks/2026-03-17_1014_fullscreen-tui-layout/TASK.md diff --git a/tasks/2026-03-17_0921_tui-rendering-bugs/TASK.md b/tasks/2026-03-17_0921_tui-rendering-bugs/TASK.md new file mode 100644 index 0000000..92f6ef2 --- /dev/null +++ b/tasks/2026-03-17_0921_tui-rendering-bugs/TASK.md @@ -0,0 +1,64 @@ +# Fix TUI rendering bugs + +## Status: Resolved + +## Scope + +Fix three bugs in the interactive TUI create flow: + +1. **Duplicate "[v]" hint** — During the provision phase, both `App` and + `ProvisionMonitor` render a "[v] show/hide logs" hint, resulting in two + lines. +2. **Ctrl-C cleanup missing** — Ink intercepts Ctrl-C in raw mode (as a + character '\x03', not a SIGINT), so the `onSignalCleanup` process-level + handlers registered by `runHeadlessFromConfig` never fire. The VM and + project directory are left behind on interrupt. +3. **Ghost UI elements** — Ink's differential rendering leaves artifacts when + the output height shrinks between phases (e.g., two-pane config builder → + single-column provision monitor). Old content remains visible on screen. + +Does **not** cover: headless mode (already works), abort during prereqs or +config phases (no VM exists yet). + +## Plan + +1. Remove the global "[v]" hint from `app.tsx` during the provision phase + — `ProvisionMonitor` already renders its own. +2. Add an `onProvisionStart` callback prop to `App` so `runCreateWizard` + can capture the `InstanceConfig` when provisioning begins. +3. After `waitUntilExit()` in `runCreateWizard`, if the exit result isn't + a success and config was captured, compute the VM config and run + `cleanupVM`. + +## Steps + +- [x] Fix duplicate "[v]" hint in `app.tsx` +- [x] Add `onProvisionStart` callback to `App` +- [x] Add cleanup logic to `runCreateWizard` after Ink exits +- [x] Clear terminal on phase transitions to prevent ghost elements +- [x] Verify no type errors + +## Outcome + +All three bugs fixed: + +1. **Duplicate hint**: Changed `app.tsx` to only show the `[v]` hint during the + `prereqs` phase. During `provision`, `ProvisionMonitor` renders its own hint. +2. **Ctrl-C cleanup**: Added `onProvisionStart` callback to `App` so + `runCreateWizard` captures the `InstanceConfig`. After `waitUntilExit()`, if + the result isn't a success and provisioning had started, it computes the VM + config and calls `cleanupVM` to delete the VM and project directory. +3. **Ghost UI elements**: Added `useLayoutEffect` in `App` that clears the + terminal (`\x1b[2J\x1b[H`) on phase transitions. Runs before Ink writes the + new frame, so the new phase content is drawn on a clean slate. + +No new type errors introduced. + +## Notes + +- Ink with raw mode stdin converts Ctrl-C to a character, not SIGINT. + `onSignalCleanup` registers on `process.on('SIGINT')`, which never fires. +- The headless pipeline's catch-block cleanup also doesn't trigger because + no error is thrown — the promise is just abandoned when the process exits. +- We import `configToVMConfig` and `cleanupVM` in `create.ts` to compute + the target from the captured config. diff --git a/tasks/2026-03-17_1014_fullscreen-tui-layout/TASK.md b/tasks/2026-03-17_1014_fullscreen-tui-layout/TASK.md new file mode 100644 index 0000000..8eba81d --- /dev/null +++ b/tasks/2026-03-17_1014_fullscreen-tui-layout/TASK.md @@ -0,0 +1,67 @@ +# Fullscreen TUI Layout + +## Status: Resolved + +## Scope + +Make every TUI phase fill the terminal using Ink's flexbox layout. Each phase +owns the entire screen, expanding areas (logs, forms) use all available space, +and help text is pinned to the bottom. + +Covers: PrereqCheck, ConfigBuilder, ProvisionMonitor, CompletionScreen, +ConfigReview, and the App root. Does not cover headless mode. + +## Plan + +1. New `useTerminalSize` hook — wraps `useStdout()`, returns `{ rows, columns }` +2. App root gets `height={rows}` on the outer Box +3. Each phase component gets `flexGrow={1}` to fill the screen +4. LogOutput / ProcessOutput get `flexGrow={1}` to expand into available space +5. Sidebar gets `flexGrow={1}` to stretch vertically in two-column layouts +6. Help text / keybinding hints pinned to bottom via spacers +7. ProvisionMonitor computes `maxLines` dynamically from `rows` + +## Steps + +- [x] Create `hooks/use-terminal-size.ts` +- [x] Update `app.tsx` — root `height={rows}`, remove `[v]` hint (moved to PrereqCheck) +- [x] Update `log-output.tsx` — `flexGrow={1}`, `overflow="hidden"` +- [x] Update `process-output.tsx` — `flexGrow={1}` on outer Box +- [x] Update `sidebar.tsx` — `flexGrow={1}` on outer Box +- [x] Update `prereq-check.tsx` — fullscreen layout, absorb `[v]` hint, spacer +- [x] Update `config-builder.tsx` — fullscreen two-pane layout, pinned keybindings +- [x] Update `provision-monitor.tsx` — fullscreen, dynamic `maxLines` +- [x] Update `completion-screen.tsx` — fullscreen with spacer +- [x] Update `config-review.tsx` — `flexGrow={1}`, spacer before hints +- [x] Verify no type errors in CLI package + +## Outcome + +All 10 files updated. Every phase now fills the terminal: + +- **App root**: `height={rows}` from `useTerminalSize` hook constrains the + entire tree to the terminal height. Removed the `[v]` hint (now in PrereqCheck). +- **PrereqCheck**: `flexGrow={1}` fills height, spacer pushes `[v]` hint to bottom. + When installing, ProcessOutput expands to fill available space. +- **ConfigBuilder**: Outer Box is `flexGrow={1}` (horizontal), left pane's form area + gets `flexGrow={1}` with `overflow="hidden"`, keybinding hints pinned at bottom. + Sidebar stretches to match via `flexGrow={1}`. Same for review sub-phase. +- **ProvisionMonitor**: `flexGrow={1}` column layout. Log viewer expands to fill + remaining space with dynamically computed `maxLines` based on `rows`. When logs + hidden, spacer fills the gap. Help text pinned at bottom. +- **CompletionScreen**: Content at top, `flexGrow={1}` spacer pushes content up. +- **ConfigReview**: `flexGrow={1}` outer Box, spacer before keybinding hints. +- **LogOutput**: `flexGrow={1}` + `overflow="hidden"` so it fills its container. +- **ProcessOutput**: `flexGrow={1}` so it expands when used inside PrereqCheck. + +No new type errors in the CLI package. Pre-existing errors in vm-cli and +host-core packages are unrelated. + +## Notes + +- `useTerminalSize` falls back to 24×80 if stdout dimensions aren't available. + It listens for `resize` events and triggers re-render. +- The `maxLines` calculation in ProvisionMonitor accounts for header border (3), + stage list, visible steps (capped at 5), log border (3), and help text (1). + Minimum is 3 lines to avoid collapsing completely on tiny terminals. +- The error phase in App also gets a spacer to fill the screen. From ded9e5fa6174fae90d467f5e5fbfc3e95952cbcc Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 10:58:25 +0100 Subject: [PATCH 04/13] feat: fullscreen TUI layout, config-driven TUI mode, --plain flag - Add useTerminalSize hook; set root Box height={rows} so every phase fills the terminal. Each component gets flexGrow={1}, pinned help text, and overflow="hidden" where needed. - ProvisionMonitor: stages and steps side-by-side, dynamic maxLines for log viewer, logs shown by default. - New ProvisionApp component and runCreateFromConfig for config-driven TUI mode (--config without --plain shows full progress UI). - --plain flag preserves the old streaming log output for CI. - Update bash/zsh completions with --plain, update docs and README. - Fix pre-existing type errors: state.test.ts noop migration return type, claw-binary.ts Bun file import. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 +- docs/headless-mode.md | 58 +++++--- packages/cli/bin/cli.tsx | 14 +- packages/cli/src/app.tsx | 78 +++++++++- packages/cli/src/commands/create.ts | 137 +++++++++++++++++- packages/cli/src/commands/index.ts | 2 +- .../cli/src/components/completion-screen.tsx | 4 +- packages/cli/src/components/config-review.tsx | 10 +- packages/cli/src/components/log-output.tsx | 2 +- .../cli/src/components/process-output.tsx | 2 +- .../cli/src/components/provision-monitor.tsx | 101 ++++++++----- packages/cli/src/components/sidebar.tsx | 2 +- packages/cli/src/hooks/use-terminal-size.ts | 31 ++++ packages/cli/src/steps/config-builder.tsx | 20 +-- packages/cli/src/steps/prereq-check.tsx | 11 +- packages/host-core/src/claw-binary.ts | 1 + packages/templates/src/completions/bash.ts | 2 +- .../src/completions/completions.test.ts | 2 + packages/templates/src/completions/zsh.ts | 3 +- packages/types/src/constants.ts | 1 + packages/types/src/index.ts | 1 + .../vm-cli/src/capabilities/state.test.ts | 2 +- 22 files changed, 383 insertions(+), 104 deletions(-) create mode 100644 packages/cli/src/hooks/use-terminal-size.ts diff --git a/README.md b/README.md index a26e3fa..d420bb9 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ provisioning, and — optionally — credential setup and OpenClaw onboarding. | Command | Description | | ------------------------------------------ | ------------------------------------------------- | | `clawctl create` | Interactive wizard | -| `clawctl create --config ` | Headless mode (config-file-driven) | +| `clawctl create --config ` | Config-driven with TUI progress | +| `clawctl create --config --plain` | Plain log output (CI/automation) | | `clawctl list` | List all instances with live status | | `clawctl status [name]` | Detailed info for one instance | | `clawctl start [name]` | Start a stopped instance | diff --git a/docs/headless-mode.md b/docs/headless-mode.md index 0752505..abed3f7 100644 --- a/docs/headless-mode.md +++ b/docs/headless-mode.md @@ -1,33 +1,44 @@ -# Headless Mode +# Config-Driven Mode -`clawctl create --config ` runs the full provisioning pipeline without -interactive prompts. Use it for CI/CD, scripted setups, or reproducible team -onboarding. +`clawctl create --config ` runs the provisioning pipeline from a JSON +config file, skipping the interactive wizard. By default it shows the same +fullscreen TUI as the wizard — with stage progress, step history, and a live +log viewer. -## When to use it +For CI/CD or piped environments, add `--plain` for a simple streaming log. -- **CI/CD pipelines** — spin up OpenClaw gateways as part of automated workflows -- **Scripted setups** — provision multiple instances from a shell script -- **Team onboarding** — share a config file so teammates get identical environments -- **Reproducible rebuilds** — recreate a VM from a checked-in config +## Modes + +| Command | Output | +|---------|--------| +| `clawctl create` | Full interactive wizard | +| `clawctl create --config ` | TUI progress (stages, steps, logs) | +| `clawctl create --config --plain` | Plain `[prefix] message` log lines | + +The TUI mode uses the alternate screen buffer with Ctrl-C cleanup — same +behavior as the interactive wizard. The `--plain` mode writes directly to +stdout and is safe for non-TTY environments. + +## When to use each mode + +- **Interactive wizard** — first-time setup, exploring options +- **Config + TUI** — re-provisioning, team onboarding, watching progress +- **Config + `--plain`** — CI/CD pipelines, scripted setups, log files ## Pipeline -The headless pipeline runs these stages in order: +All three modes run the same provisioning stages: -1. **Load config** — read and validate the JSON config file -2. **Check prerequisites** — macOS, Apple Silicon, Homebrew -3. **Install Lima** — via Homebrew, if not already present -4. **Create and provision VM** — generate lima.yaml, boot Ubuntu 24.04, run provisioning scripts -5. **Verify installed tools** — Node.js 22, Tailscale, Homebrew, 1Password CLI -6. **Set up 1Password** — if `services.onePassword` is configured +1. **Check prerequisites** — macOS, Apple Silicon, Homebrew +2. **Install Lima** — via Homebrew, if not already present +3. **Create and provision VM** — generate lima.yaml, boot Ubuntu 24.04, run provisioning +4. **Verify installed tools** — Node.js 22, Tailscale, Homebrew, 1Password CLI +5. **Set up 1Password** — if `services.onePassword` is configured +6. **Resolve secrets** — if `op://` references are present in the config 7. **Connect Tailscale** — if `network.tailscale` is configured 8. **Bootstrap gateway** — if `provider` is configured (runs `openclaw onboard --non-interactive`) 9. **Register instance** — write `clawctl.json` and update the instance registry -Progress is printed as `[prefix] message` lines — suitable for terminals and -CI logs. - Without a `provider` section, onboarding is skipped. Run `openclaw onboard` in the VM when ready. @@ -60,6 +71,15 @@ For the full schema and all available fields, see the ## Tips for CI +### Use `--plain` for automation + +CI environments typically don't have a TTY, so the TUI can't render. Use +`--plain` explicitly to get clean, parseable log output: + +```bash +clawctl create --config ./vm-bootstrap.json --plain +``` + ### Use `env://` for secrets Keep secrets out of config files by using `env://` references that resolve diff --git a/packages/cli/bin/cli.tsx b/packages/cli/bin/cli.tsx index b7d2e90..8a4fdab 100755 --- a/packages/cli/bin/cli.tsx +++ b/packages/cli/bin/cli.tsx @@ -4,7 +4,8 @@ import { Command } from "commander"; import pkg from "../../../package.json"; import { LimaDriver, BIN_NAME } from "@clawctl/host-core"; import { - runCreateHeadless, + runCreateFromConfig, + runCreatePlain, runCreateWizard, runList, runStatus, @@ -41,11 +42,14 @@ const program = new Command() program .command("create") .description("Create a new OpenClaw instance") - .option("--config ", "Config file for headless mode") - .action(async (opts: { config?: string }) => { + .option("--config ", "Config file (skips wizard, shows TUI progress)") + .option("--plain", "Plain log output instead of TUI (for CI/automation)") + .action(async (opts: { config?: string; plain?: boolean }) => { try { - if (opts.config) { - await runCreateHeadless(driver, opts.config); + if (opts.config && opts.plain) { + await runCreatePlain(driver, opts.config); + } else if (opts.config) { + await runCreateFromConfig(driver, opts.config); } else { await runCreateWizard(driver); } diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index 878fdb4..e3ce5ba 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -6,6 +6,7 @@ import { ProvisionMonitor } from "./components/provision-monitor.js"; import { CompletionScreen } from "./components/completion-screen.js"; import { useVerboseMode } from "./hooks/use-verbose-mode.js"; import { VerboseContext } from "./hooks/verbose-context.js"; +import { useTerminalSize } from "./hooks/use-terminal-size.js"; import type { InstanceConfig } from "@clawctl/types"; import type { VMDriver, HeadlessResult } from "@clawctl/host-core"; @@ -16,11 +17,75 @@ export interface AppResult { result: HeadlessResult; } +// -- ProvisionApp: config-driven TUI (skips wizard, goes straight to provision) -- + +interface ProvisionAppProps { + driver: VMDriver; + config: InstanceConfig; +} + +export function ProvisionApp({ driver, config }: ProvisionAppProps) { + const { exit } = useApp(); + const { verbose } = useVerboseMode(); + const [phase, setPhase] = useState<"provision" | "done" | "error">("provision"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const exited = useRef(false); + const { rows } = useTerminalSize(); + + useEffect(() => { + if (phase === "done" && result && !exited.current) { + exited.current = true; + setTimeout(() => { + exit({ action: "created", result } as AppResult); + }, 1000); + } + }, [phase, result]); + + return ( + + + {phase === "provision" && ( + { + setResult(res); + setPhase("done"); + }} + onError={(err) => { + setError(err.message); + setPhase("error"); + }} + /> + )} + + {phase === "done" && result && } + + {phase === "error" && ( + + + {"\u2717"} Provisioning failed + + + {error} + + + + )} + + + ); +} + +// -- App: full interactive wizard (prereqs → config → provision → done) -- + interface AppProps { driver: VMDriver; + onProvisionStart?: (config: InstanceConfig) => void; } -export function App({ driver }: AppProps) { +export function App({ driver, onProvisionStart }: AppProps) { const { exit } = useApp(); const [phase, setPhase] = useState("prereqs"); const { verbose } = useVerboseMode(); @@ -28,6 +93,7 @@ export function App({ driver }: AppProps) { const [result, setResult] = useState(null); const [error, setError] = useState(null); const exited = useRef(false); + const { rows } = useTerminalSize(); // Exit Ink on completion or error so waitUntilExit() resolves useEffect(() => { @@ -42,7 +108,7 @@ export function App({ driver }: AppProps) { return ( - + {phase === "prereqs" && ( setPhase("config")} /> )} @@ -51,6 +117,7 @@ export function App({ driver }: AppProps) { { setConfig(cfg); + onProvisionStart?.(cfg); setPhase("provision"); }} /> @@ -81,12 +148,7 @@ export function App({ driver }: AppProps) { {error} - - )} - - {(phase === "prereqs" || phase === "provision") && ( - - Press [v] to {verbose ? "hide" : "show"} process logs + )} diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 3a41383..3c3da16 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,26 +1,94 @@ import { openSync } from "node:fs"; import { ReadStream } from "node:tty"; import type { VMDriver } from "@clawctl/host-core"; -import { addInstance, runHeadless } from "@clawctl/host-core"; +import { addInstance, loadConfig, runHeadless, configToVMConfig, cleanupVM, BIN_NAME } from "@clawctl/host-core"; import type { RegistryEntry, HeadlessResult } from "@clawctl/host-core"; +import type { InstanceConfig } from "@clawctl/types"; /** - * Run the headless create path: load config, provision, register. + * Run the plain (CI-friendly) create path: load config, provision with + * streaming log output, register. No TUI. */ -export async function runCreateHeadless(driver: VMDriver, configPath: string): Promise { +export async function runCreatePlain(driver: VMDriver, configPath: string): Promise { const result = await runHeadless(driver, configPath); await registerInstance(result, driver.name); } +/** + * Run config-driven create with the TUI provision monitor. + * Loads the config from disk, then renders ProvisionApp for the visual + * progress view (stages, steps, logs). + */ +export async function runCreateFromConfig(driver: VMDriver, configPath: string): Promise { + const config = await loadConfig(configPath); + + const React = (await import("react")).default; + const { render } = await import("ink"); + const { ProvisionApp } = await import("../app.js"); + + let inkStdin: ReadStream | undefined; + try { + inkStdin = new ReadStream(openSync("/dev/tty", "r")); + } catch { + // /dev/tty unavailable — Ink will use process.stdin + } + + const useAltScreen = process.stdout.isTTY; + if (useAltScreen) { + process.stdout.write("\x1b[?1049h"); + } + + const renderOpts = inkStdin ? { stdin: inkStdin } : undefined; + const instance = render( + React.createElement(ProvisionApp, { driver, config }), + renderOpts, + ); + + let exitResult: unknown; + try { + exitResult = await instance.waitUntilExit(); + } finally { + if (useAltScreen) { + process.stdout.write("\x1b[?1049l"); + } + inkStdin?.destroy(); + } + + if ( + exitResult && + typeof exitResult === "object" && + "action" in exitResult && + (exitResult as { action: string }).action === "created" + ) { + const { result } = exitResult as { action: string; result: HeadlessResult }; + await registerInstance(result, driver.name); + printSummary(result); + } else { + // Ctrl-C or error — clean up VM and project dir + const vmConfig = configToVMConfig(config); + await cleanupVM(driver, vmConfig.vmName, vmConfig.projectDir); + process.exit(130); + } +} + /** * Run the interactive TUI create path. - * The TUI collects an InstanceConfig, then delegates to the headless pipeline. + * + * Uses the alternate screen buffer so each phase renders on a clean canvas + * without ghost elements. On exit, the original terminal content is restored + * and a summary is printed to the main buffer. */ export async function runCreateWizard(driver: VMDriver): Promise { const React = (await import("react")).default; const { render } = await import("ink"); const { App } = await import("../app.js"); + // Capture config when provisioning starts so we can clean up on Ctrl-C. + // Ink intercepts Ctrl-C in raw mode (as a character, not SIGINT), so the + // process-level signal handlers registered by runHeadlessFromConfig never + // fire. We handle cleanup here instead. + let provisionConfig: InstanceConfig | null = null; + // Give Ink its own stdin stream via /dev/tty so it never touches // process.stdin. After Ink exits, the subprocess can inherit // process.stdin cleanly without competing for bytes on fd 0. @@ -31,12 +99,35 @@ export async function runCreateWizard(driver: VMDriver): Promise { // /dev/tty unavailable (CI, piped, etc.) — Ink will use process.stdin } + // Enter alternate screen buffer for a clean canvas. This prevents ghost + // elements from Ink's differential rendering when the output height + // shrinks between phases. On exit, the original terminal is restored. + const useAltScreen = process.stdout.isTTY; + if (useAltScreen) { + process.stdout.write("\x1b[?1049h"); + } + const renderOpts = inkStdin ? { stdin: inkStdin } : undefined; - const instance = render(React.createElement(App, { driver }), renderOpts); - const exitResult = await instance.waitUntilExit(); + const instance = render( + React.createElement(App, { + driver, + onProvisionStart: (cfg: InstanceConfig) => { + provisionConfig = cfg; + }, + }), + renderOpts, + ); - // Destroy Ink's private stdin - inkStdin?.destroy(); + let exitResult: unknown; + try { + exitResult = await instance.waitUntilExit(); + } finally { + // Leave alternate screen buffer — restores original terminal content + if (useAltScreen) { + process.stdout.write("\x1b[?1049l"); + } + inkStdin?.destroy(); + } // Register instance if provisioning completed successfully if ( @@ -47,7 +138,37 @@ export async function runCreateWizard(driver: VMDriver): Promise { ) { const { result } = exitResult as { action: string; result: HeadlessResult }; await registerInstance(result, driver.name); + printSummary(result); + } else if (provisionConfig) { + // Ctrl-C or error during provisioning — clean up the VM and project dir. + // The abandoned headless pipeline's subprocesses keep the event loop + // alive, so we must force-exit after cleanup. + const vmConfig = configToVMConfig(provisionConfig); + await cleanupVM(driver, vmConfig.vmName, vmConfig.projectDir); + process.exit(130); + } +} + +/** Print a summary to the main terminal after leaving the alternate screen. */ +function printSummary(result: HeadlessResult): void { + const dashboardUrl = result.gatewayToken + ? `http://localhost:${result.gatewayPort}/#token=${result.gatewayToken}` + : `http://localhost:${result.gatewayPort}`; + + console.log(`\n\x1b[32m\u2713\x1b[0m \x1b[1m${result.name}\x1b[0m is ready\n`); + console.log(` Dashboard ${dashboardUrl}`); + if (result.tailscaleUrl) { + console.log(` Tailscale ${result.tailscaleUrl}`); + } + console.log(` Config ${result.projectDir}/clawctl.json`); + console.log(); + console.log(` ${BIN_NAME} shell Enter the VM`); + console.log(` ${BIN_NAME} oc dashboard Open the dashboard`); + console.log(` ${BIN_NAME} status Check instance health`); + if (!result.providerType) { + console.log(` ${BIN_NAME} oc onboard Configure a provider`); } + console.log(); } async function registerInstance(result: HeadlessResult, driverName: string): Promise { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 9f9eb00..353ab73 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,4 +1,4 @@ -export { runCreateHeadless, runCreateWizard } from "./create.js"; +export { runCreateFromConfig, runCreatePlain, runCreateWizard } from "./create.js"; export { runList } from "./list.js"; export { runStatus } from "./status.js"; export { runStart } from "./start.js"; diff --git a/packages/cli/src/components/completion-screen.tsx b/packages/cli/src/components/completion-screen.tsx index 848dc49..5b71328 100644 --- a/packages/cli/src/components/completion-screen.tsx +++ b/packages/cli/src/components/completion-screen.tsx @@ -13,7 +13,7 @@ export function CompletionScreen({ result }: CompletionScreenProps) { : `http://localhost:${result.gatewayPort}`; return ( - + {"\u2713"} {result.name} is ready @@ -64,6 +64,8 @@ export function CompletionScreen({ result }: CompletionScreenProps) { )} + + ); } diff --git a/packages/cli/src/components/config-review.tsx b/packages/cli/src/components/config-review.tsx index 61945ac..d9a5d43 100644 --- a/packages/cli/src/components/config-review.tsx +++ b/packages/cli/src/components/config-review.tsx @@ -16,7 +16,7 @@ function MaskedValue({ value, label }: { value?: string; label?: string }) { {"\u2500\u2500"} not configured {"\u2500\u2500"} ); - const masked = value.slice(0, 6) + "\u2022".repeat(Math.min(value.length - 6, 18)); + const masked = value.slice(0, 6) + "\u2022".repeat(Math.max(0, Math.min(value.length - 6, 18))); return ( {label ? `${label} ` : ""} @@ -36,7 +36,7 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) ); } -export function ConfigReview({ config, validationErrors, validationWarnings }: ConfigReviewProps) { +export function ConfigReview({ config, validationErrors, validationWarnings, focused }: ConfigReviewProps) { const resources = config.resources ?? {}; const cpus = resources.cpus ?? 4; const memory = resources.memory ?? "8GiB"; @@ -49,7 +49,7 @@ export function ConfigReview({ config, validationErrors, validationWarnings }: C : null; return ( - + Review Configuration @@ -164,7 +164,9 @@ export function ConfigReview({ config, validationErrors, validationWarnings }: C - + + + {validationErrors.length > 0 ? ( [Esc] Back to editor (fix errors first) ) : ( diff --git a/packages/cli/src/components/log-output.tsx b/packages/cli/src/components/log-output.tsx index 5adce14..cafd3e4 100644 --- a/packages/cli/src/components/log-output.tsx +++ b/packages/cli/src/components/log-output.tsx @@ -10,7 +10,7 @@ export function LogOutput({ lines, maxLines = 10 }: LogOutputProps) { const visible = lines.slice(-maxLines); return ( - + {visible.map((line, i) => ( {line} diff --git a/packages/cli/src/components/process-output.tsx b/packages/cli/src/components/process-output.tsx index d8e1eb7..8791e4d 100644 --- a/packages/cli/src/components/process-output.tsx +++ b/packages/cli/src/components/process-output.tsx @@ -14,7 +14,7 @@ export function ProcessOutput({ label, logs, verbose, maxLines = 20 }: ProcessOu const lastLine = logs.length > 0 ? logs[logs.length - 1] : undefined; return ( - + {verbose && logs.length > 0 ? ( diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index 666ec0b..771cfef 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -3,6 +3,7 @@ import { Text, Box, useInput } from "ink"; import { Spinner } from "./spinner.js"; import { LogOutput } from "./log-output.js"; import { VerboseContext } from "../hooks/verbose-context.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; import type { VMDriver } from "@clawctl/host-core"; import { runHeadlessFromConfig } from "@clawctl/host-core"; import type { @@ -39,10 +40,11 @@ interface ProvisionMonitorProps { export function ProvisionMonitor({ driver, config, onComplete, onError }: ProvisionMonitorProps) { const verbose = useContext(VerboseContext); + const { rows } = useTerminalSize(); const [stages, setStages] = useState>(() => new Map()); const [steps, setSteps] = useState([]); const [logs, setLogs] = useState([]); - const [showLogs, setShowLogs] = useState(verbose); + const [showLogs, setShowLogs] = useState(true); useInput((input) => { if (input === "v") setShowLogs((s) => !s); @@ -89,52 +91,71 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis ...(config.provider ? ["bootstrap" as HeadlessStage] : []), ]; + // The status panel has a fixed height: 1 header + stage count. + // Steps column scrolls within that same height. + const statusHeight = 1 + activeStages.length + 2; + + // Compute dynamic maxLines for the log viewer: + // header border(3) + margin(1) + statusHeight + margin(1) + log border(3) + help(1) + const fixed = 3 + 1 + statusHeight + 1 + 3 + 1; + const maxLines = Math.max(3, rows - fixed); + + // Show the most recent steps that fit (statusHeight - 1 for header) + const maxSteps = Math.max(1, statusHeight - 1); + const visibleSteps = steps.slice(-maxSteps); + return ( - + Provisioning: {config.name} - - {activeStages.map((stageId) => { - const info = stages.get(stageId); - const status = info?.status ?? "pending"; - const label = STAGE_LABELS[stageId]; - const detail = info?.detail; - - return ( - - {status === "done" ? ( - {"\u2713"} - ) : status === "running" ? ( - - ) : status === "error" ? ( - {"\u2717"} - ) : ( - {"\u25cb"} - )} - - {label} + {/* Stages (left) + Steps (right) side by side, fixed height */} + + + Stages + {activeStages.map((stageId) => { + const info = stages.get(stageId); + const status = info?.status ?? "pending"; + const label = STAGE_LABELS[stageId]; + const detail = info?.detail; + + return ( + + {status === "done" ? ( + {"\u2713"} + ) : status === "running" ? ( + + ) : status === "error" ? ( + {"\u2717"} + ) : ( + {"\u25cb"} + )} + + {label} + + {detail && status === "done" && {detail}} + + ); + })} + + + {steps.length > 0 && ( + + Steps + {visibleSteps.map((step, i) => ( + + {"\u2500"} {step} - {detail && status === "done" && {detail}} - - ); - })} + ))} + + )} - {steps.length > 0 && ( - - {steps.slice(-5).map((step, i) => ( - - {"\u2500"} {step} - - ))} - - )} - - {showLogs && logs.length > 0 && ( + {showLogs && logs.length > 0 ? ( - + + ) : ( + )} - + [v] {showLogs ? "hide" : "show"} logs diff --git a/packages/cli/src/components/sidebar.tsx b/packages/cli/src/components/sidebar.tsx index c50af36..0236cb5 100644 --- a/packages/cli/src/components/sidebar.tsx +++ b/packages/cli/src/components/sidebar.tsx @@ -9,7 +9,7 @@ interface SidebarProps { export function Sidebar({ title, lines, width = 36 }: SidebarProps) { return ( - + {"\u2139"} {title} diff --git a/packages/cli/src/hooks/use-terminal-size.ts b/packages/cli/src/hooks/use-terminal-size.ts new file mode 100644 index 0000000..d9e9ad9 --- /dev/null +++ b/packages/cli/src/hooks/use-terminal-size.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from "react"; +import { useStdout } from "ink"; + +interface TerminalSize { + rows: number; + columns: number; +} + +export function useTerminalSize(): TerminalSize { + const { stdout } = useStdout(); + + const [size, setSize] = useState(() => ({ + rows: stdout?.rows ?? 24, + columns: stdout?.columns ?? 80, + })); + + useEffect(() => { + if (!stdout) return; + + const onResize = () => { + setSize({ rows: stdout.rows, columns: stdout.columns }); + }; + + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + }; + }, [stdout]); + + return size; +} diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index 7cb2565..db40bc9 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -6,7 +6,7 @@ import { FormField } from "../components/form-field.js"; import { FormSection } from "../components/form-section.js"; import { Sidebar, SIDEBAR_HELP } from "../components/sidebar.js"; import { ConfigReview } from "../components/config-review.js"; -import { instanceConfigSchema, providerSchema, ALL_PROVIDER_TYPES } from "@clawctl/types"; +import { instanceConfigSchema, providerSchema, ALL_PROVIDER_TYPES, DEFAULT_PROJECT_BASE } from "@clawctl/types"; import type { InstanceConfig } from "@clawctl/types"; type Phase = "form" | "review"; @@ -151,7 +151,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const buildConfig = (): InstanceConfig => { const config: InstanceConfig = { name: name.trim(), - project: project.trim() || `~/agents/${name.trim()}`, + project: project.trim() || `${DEFAULT_PROJECT_BASE}/${name.trim()}`, }; const cpuNum = parseInt(cpus, 10); @@ -336,7 +336,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { setEditing(false); // Auto-fill project if empty if (currentFocus === "name" && !project && name) { - setProject(`~/agents/${name.trim()}`); + setProject(`${DEFAULT_PROJECT_BASE}/${name.trim()}`); } } return; @@ -385,7 +385,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { if (phase === "review") { const { errors, warnings } = validate(); return ( - + ({ label: t, value: t })); return ( - + clawctl create Configure a new OpenClaw instance - + {/* Instance fields (always visible) */} {currentFocus === "name" && editing ? ( @@ -450,7 +450,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { ) : ( @@ -458,7 +458,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { label="Project" value={project} status={fieldStatus("project")} - placeholder={name ? `~/agents/${name}` : "~/agents/my-agent"} + placeholder={name ? `${DEFAULT_PROJECT_BASE}/${name}` : `${DEFAULT_PROJECT_BASE}/my-agent`} /> )} @@ -845,8 +845,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { - {/* Keybinding hints */} - + {/* Keybinding hints (pinned bottom) */} + [{"\u2191\u2193"}] navigate {"\u00b7"} [Enter] {editing ? "confirm" : "edit/expand"}{" "} {"\u00b7"} [Esc] {editing ? "cancel" : "collapse"} {"\u00b7"} [R] review diff --git a/packages/cli/src/steps/prereq-check.tsx b/packages/cli/src/steps/prereq-check.tsx index 02319ad..12276f2 100644 --- a/packages/cli/src/steps/prereq-check.tsx +++ b/packages/cli/src/steps/prereq-check.tsx @@ -68,7 +68,7 @@ export function PrereqCheck({ driver, onComplete }: PrereqCheckProps) { ); return ( - + clawctl @@ -96,7 +96,7 @@ export function PrereqCheck({ driver, onComplete }: PrereqCheckProps) { )} {phase === "installing" && ( - + )} + + {/* Spacer pushes help text to bottom */} + {phase !== "installing" && } + + + Press [v] to {verbose ? "hide" : "show"} process logs + ); } diff --git a/packages/host-core/src/claw-binary.ts b/packages/host-core/src/claw-binary.ts index 3286afd..3132613 100644 --- a/packages/host-core/src/claw-binary.ts +++ b/packages/host-core/src/claw-binary.ts @@ -1,3 +1,4 @@ +// @ts-expect-error — Bun `with { type: "file" }` import resolves to a string path at runtime import embeddedClawPath from "../../../dist/claw" with { type: "file" }; import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; diff --git a/packages/templates/src/completions/bash.ts b/packages/templates/src/completions/bash.ts index ba191f3..2278d1d 100644 --- a/packages/templates/src/completions/bash.ts +++ b/packages/templates/src/completions/bash.ts @@ -97,7 +97,7 @@ export function generateBashCompletion(binName: string): string { case "$cmd" in create) - COMPREPLY=( $(compgen -W "--config --help" -- "$cur") ) + COMPREPLY=( $(compgen -W "--config --plain --help" -- "$cur") ) ;; list) COMPREPLY=( $(compgen -W "--help" -- "$cur") ) diff --git a/packages/templates/src/completions/completions.test.ts b/packages/templates/src/completions/completions.test.ts index 42efe5a..c5e28bc 100644 --- a/packages/templates/src/completions/completions.test.ts +++ b/packages/templates/src/completions/completions.test.ts @@ -57,6 +57,7 @@ describe("generateBashCompletion", () => { test("includes per-command options", () => { expect(script).toContain("--config"); + expect(script).toContain("--plain"); expect(script).toContain("--purge"); expect(script).toContain("--global"); expect(script).toContain("--instance"); @@ -142,6 +143,7 @@ describe("generateZshCompletion", () => { test("includes per-command options", () => { expect(script).toContain("--config"); + expect(script).toContain("--plain"); expect(script).toContain("--purge"); expect(script).toContain("--global"); expect(script).toContain("--instance"); diff --git a/packages/templates/src/completions/zsh.ts b/packages/templates/src/completions/zsh.ts index 6ef2347..41a54e5 100644 --- a/packages/templates/src/completions/zsh.ts +++ b/packages/templates/src/completions/zsh.ts @@ -105,7 +105,8 @@ export function generateZshCompletion(binName: string): string { case \${words[1]} in create) _arguments ${BS} - '--config[Config file for headless mode]:config file:_files' ${BS} + '--config[Config file (skips wizard, shows TUI progress)]:config file:_files' ${BS} + '--plain[Plain log output instead of TUI (for CI/automation)]' ${BS} '--help[Show help]' ;; list) diff --git a/packages/types/src/constants.ts b/packages/types/src/constants.ts index 4123a66..a0106b9 100644 --- a/packages/types/src/constants.ts +++ b/packages/types/src/constants.ts @@ -1,4 +1,5 @@ export const PROJECT_MOUNT_POINT = "/mnt/project"; +export const DEFAULT_PROJECT_BASE = "~/openclaw-vms"; export const GATEWAY_PORT = 18789; export const CHECKPOINT_REQUEST_FILE = ".checkpoint-request"; export const PROVISION_CONFIG_FILE = "provision.json"; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d07aef6..3aaa056 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -40,6 +40,7 @@ export { CHECKPOINT_REQUEST_FILE, PROVISION_CONFIG_FILE, CLAW_BIN_PATH, + DEFAULT_PROJECT_BASE, LIFECYCLE_PHASES, phaseReached, } from "./constants.js"; diff --git a/packages/vm-cli/src/capabilities/state.test.ts b/packages/vm-cli/src/capabilities/state.test.ts index fd570d8..2aba61e 100644 --- a/packages/vm-cli/src/capabilities/state.test.ts +++ b/packages/vm-cli/src/capabilities/state.test.ts @@ -40,7 +40,7 @@ describe("state", () => { }); describe("findMigrationPath", () => { - const noop = async () => ({ status: "ok" as const, changed: false }); + const noop = async () => ({ name: "test", status: "unchanged" as const }); it("returns empty array when not installed", () => { const cap = makeCap("foo", "2.0.0", [{ from: "1.0.0", to: "2.0.0", run: noop }]); From 94d5dd0b47f4efe25f460eb4211b91b7786d0be1 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:00:24 +0100 Subject: [PATCH 05/13] fix: lint and format issues Remove unused `focused` destructure in ConfigReview, remove unused VerboseContext import in ProvisionMonitor, apply Prettier formatting. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/headless-mode.md | 8 ++++---- packages/cli/src/commands/create.ts | 14 +++++++++----- packages/cli/src/components/config-review.tsx | 2 +- packages/cli/src/components/provision-monitor.tsx | 12 +++++++----- packages/cli/src/components/sidebar.tsx | 9 ++++++++- packages/cli/src/steps/config-builder.tsx | 15 ++++++++++++--- 6 files changed, 41 insertions(+), 19 deletions(-) diff --git a/docs/headless-mode.md b/docs/headless-mode.md index abed3f7..7063d7d 100644 --- a/docs/headless-mode.md +++ b/docs/headless-mode.md @@ -9,10 +9,10 @@ For CI/CD or piped environments, add `--plain` for a simple streaming log. ## Modes -| Command | Output | -|---------|--------| -| `clawctl create` | Full interactive wizard | -| `clawctl create --config ` | TUI progress (stages, steps, logs) | +| Command | Output | +| ---------------------------------------- | ---------------------------------- | +| `clawctl create` | Full interactive wizard | +| `clawctl create --config ` | TUI progress (stages, steps, logs) | | `clawctl create --config --plain` | Plain `[prefix] message` log lines | The TUI mode uses the alternate screen buffer with Ctrl-C cleanup — same diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 3c3da16..87beae6 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,7 +1,14 @@ import { openSync } from "node:fs"; import { ReadStream } from "node:tty"; import type { VMDriver } from "@clawctl/host-core"; -import { addInstance, loadConfig, runHeadless, configToVMConfig, cleanupVM, BIN_NAME } from "@clawctl/host-core"; +import { + addInstance, + loadConfig, + runHeadless, + configToVMConfig, + cleanupVM, + BIN_NAME, +} from "@clawctl/host-core"; import type { RegistryEntry, HeadlessResult } from "@clawctl/host-core"; import type { InstanceConfig } from "@clawctl/types"; @@ -39,10 +46,7 @@ export async function runCreateFromConfig(driver: VMDriver, configPath: string): } const renderOpts = inkStdin ? { stdin: inkStdin } : undefined; - const instance = render( - React.createElement(ProvisionApp, { driver, config }), - renderOpts, - ); + const instance = render(React.createElement(ProvisionApp, { driver, config }), renderOpts); let exitResult: unknown; try { diff --git a/packages/cli/src/components/config-review.tsx b/packages/cli/src/components/config-review.tsx index d9a5d43..7a00e0c 100644 --- a/packages/cli/src/components/config-review.tsx +++ b/packages/cli/src/components/config-review.tsx @@ -36,7 +36,7 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) ); } -export function ConfigReview({ config, validationErrors, validationWarnings, focused }: ConfigReviewProps) { +export function ConfigReview({ config, validationErrors, validationWarnings }: ConfigReviewProps) { const resources = config.resources ?? {}; const cpus = resources.cpus ?? 4; const memory = resources.memory ?? "8GiB"; diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index 771cfef..5c7da99 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect, useCallback, useContext } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Text, Box, useInput } from "ink"; import { Spinner } from "./spinner.js"; import { LogOutput } from "./log-output.js"; -import { VerboseContext } from "../hooks/verbose-context.js"; import { useTerminalSize } from "../hooks/use-terminal-size.js"; import type { VMDriver } from "@clawctl/host-core"; import { runHeadlessFromConfig } from "@clawctl/host-core"; @@ -39,7 +38,6 @@ interface ProvisionMonitorProps { } export function ProvisionMonitor({ driver, config, onComplete, onError }: ProvisionMonitorProps) { - const verbose = useContext(VerboseContext); const { rows } = useTerminalSize(); const [stages, setStages] = useState>(() => new Map()); const [steps, setSteps] = useState([]); @@ -113,7 +111,9 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis {/* Stages (left) + Steps (right) side by side, fixed height */} - Stages + + Stages + {activeStages.map((stageId) => { const info = stages.get(stageId); const status = info?.status ?? "pending"; @@ -142,7 +142,9 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis {steps.length > 0 && ( - Steps + + Steps + {visibleSteps.map((step, i) => ( {"\u2500"} {step} diff --git a/packages/cli/src/components/sidebar.tsx b/packages/cli/src/components/sidebar.tsx index 0236cb5..ac8d1d5 100644 --- a/packages/cli/src/components/sidebar.tsx +++ b/packages/cli/src/components/sidebar.tsx @@ -9,7 +9,14 @@ interface SidebarProps { export function Sidebar({ title, lines, width = 36 }: SidebarProps) { return ( - + {"\u2139"} {title} diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index db40bc9..3f097d1 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -6,7 +6,12 @@ import { FormField } from "../components/form-field.js"; import { FormSection } from "../components/form-section.js"; import { Sidebar, SIDEBAR_HELP } from "../components/sidebar.js"; import { ConfigReview } from "../components/config-review.js"; -import { instanceConfigSchema, providerSchema, ALL_PROVIDER_TYPES, DEFAULT_PROJECT_BASE } from "@clawctl/types"; +import { + instanceConfigSchema, + providerSchema, + ALL_PROVIDER_TYPES, + DEFAULT_PROJECT_BASE, +} from "@clawctl/types"; import type { InstanceConfig } from "@clawctl/types"; type Phase = "form" | "review"; @@ -450,7 +455,9 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { ) : ( @@ -458,7 +465,9 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { label="Project" value={project} status={fieldStatus("project")} - placeholder={name ? `${DEFAULT_PROJECT_BASE}/${name}` : `${DEFAULT_PROJECT_BASE}/my-agent`} + placeholder={ + name ? `${DEFAULT_PROJECT_BASE}/${name}` : `${DEFAULT_PROJECT_BASE}/my-agent` + } /> )} From f2f6db311d18705c52c362f0e16611ec2a1bb81b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:10:19 +0100 Subject: [PATCH 06/13] fix: provision monitor stages overflow and steps header scrolling - Move column headers ("Stages" / "Steps") outside the fixed-height container so they stay pinned when steps scroll. - Add overflow="hidden" to the stages column so long detail text (e.g. "VM provisioned", "Token validated") doesn't wrap and break the two-column layout. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/components/provision-monitor.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index 5c7da99..ae86307 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -89,13 +89,12 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis ...(config.provider ? ["bootstrap" as HeadlessStage] : []), ]; - // The status panel has a fixed height: 1 header + stage count. - // Steps column scrolls within that same height. - const statusHeight = 1 + activeStages.length + 2; + // The status panel has a fixed height: stage count + 2 extra for steps. + const statusHeight = activeStages.length + 2; // Compute dynamic maxLines for the log viewer: - // header border(3) + margin(1) + statusHeight + margin(1) + log border(3) + help(1) - const fixed = 3 + 1 + statusHeight + 1 + 3 + 1; + // header border(3) + margin(1) + column headers(1) + statusHeight + margin(1) + log border(3) + help(1) + const fixed = 3 + 1 + 1 + statusHeight + 1 + 3 + 1; const maxLines = Math.max(3, rows - fixed); // Show the most recent steps that fit (statusHeight - 1 for header) @@ -108,12 +107,25 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis Provisioning: {config.name} - {/* Stages (left) + Steps (right) side by side, fixed height */} - - + {/* Column headers */} + + Stages + + {steps.length > 0 && ( + + + Steps + + + )} + + + {/* Stages (left) + Steps (right) side by side, fixed height */} + + {activeStages.map((stageId) => { const info = stages.get(stageId); const status = info?.status ?? "pending"; @@ -142,9 +154,6 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis {steps.length > 0 && ( - - Steps - {visibleSteps.map((step, i) => ( {"\u2500"} {step} From 364338fcc74e6e4039c8c8b130bbd6fdbdae5318 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:11:19 +0100 Subject: [PATCH 07/13] fix: widen stages column from 32 to 44 chars Gives room for stage labels with detail text (e.g. "Setting up 1Password Token validated (account)") without truncating. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/components/provision-monitor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index ae86307..f490e16 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -109,7 +109,7 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis {/* Column headers */} - + Stages @@ -125,7 +125,7 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis {/* Stages (left) + Steps (right) side by side, fixed height */} - + {activeStages.map((stageId) => { const info = stages.get(stageId); const status = info?.status ?? "pending"; From 98cbf31cef5ab824090fd39e4e77e8cc2f428373 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:12:31 +0100 Subject: [PATCH 08/13] fix: use dynamic flexbox sizing for stages column Replace fixed width={44} with flexShrink={0} so the stages column sizes to its content. Steps column takes remaining space via flexGrow={1}. Headers are inside each column with overflow="hidden" only on the steps content area. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/components/provision-monitor.tsx | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index f490e16..8d4db23 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -89,12 +89,12 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis ...(config.provider ? ["bootstrap" as HeadlessStage] : []), ]; - // The status panel has a fixed height: stage count + 2 extra for steps. - const statusHeight = activeStages.length + 2; + // The status panel: header(1) + stages + 2 extra rows for steps overflow. + const statusHeight = 1 + activeStages.length + 2; // Compute dynamic maxLines for the log viewer: - // header border(3) + margin(1) + column headers(1) + statusHeight + margin(1) + log border(3) + help(1) - const fixed = 3 + 1 + 1 + statusHeight + 1 + 3 + 1; + // header border(3) + margin(1) + statusHeight + margin(1) + log border(3) + help(1) + const fixed = 3 + 1 + statusHeight + 1 + 3 + 1; const maxLines = Math.max(3, rows - fixed); // Show the most recent steps that fit (statusHeight - 1 for header) @@ -107,25 +107,13 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis Provisioning: {config.name} - {/* Column headers */} - - + {/* Stages (left) + Steps (right) side by side */} + + {/* Stages column — sizes to content */} + Stages - - {steps.length > 0 && ( - - - Steps - - - )} - - - {/* Stages (left) + Steps (right) side by side, fixed height */} - - {activeStages.map((stageId) => { const info = stages.get(stageId); const status = info?.status ?? "pending"; @@ -152,13 +140,19 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis })} + {/* Steps column — fills remaining width, scrolls within fixed height */} {steps.length > 0 && ( - - {visibleSteps.map((step, i) => ( - - {"\u2500"} {step} - - ))} + + + Steps + + + {visibleSteps.map((step, i) => ( + + {"\u2500"} {step} + + ))} + )} From 8ffdef4d19128e686a4c28a6cbaa7d4cc8aa6099 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:14:12 +0100 Subject: [PATCH 09/13] fix: stable 50/50 column split for stages and steps Use flexGrow={1} flexBasis={0} on both columns so they split equally from the start. Prevents layout jumps when stage details appear or steps accumulate. Steps column always renders (empty until first step). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/components/provision-monitor.tsx | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index 8d4db23..6023e6a 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -109,8 +109,8 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis {/* Stages (left) + Steps (right) side by side */} - {/* Stages column — sizes to content */} - + {/* Stages column — 50/50 split */} + Stages @@ -140,21 +140,19 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis })} - {/* Steps column — fills remaining width, scrolls within fixed height */} - {steps.length > 0 && ( - - - Steps - - - {visibleSteps.map((step, i) => ( - - {"\u2500"} {step} - - ))} - + {/* Steps column — 50/50 split, scrolls within fixed height */} + + + Steps + + + {visibleSteps.map((step, i) => ( + + {"\u2500"} {step} + + ))} - )} + {showLogs && logs.length > 0 ? ( From 2ff4842b4839bd8c1ffaac75ced48fe2d43aff6e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:14:55 +0100 Subject: [PATCH 10/13] fix: process hangs after successful config-driven deploy The headless pipeline's subprocesses keep the event loop alive after Ink exits. Add explicit process.exit(0) after registering the instance and printing the summary. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/create.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 87beae6..8caeed3 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -67,6 +67,8 @@ export async function runCreateFromConfig(driver: VMDriver, configPath: string): const { result } = exitResult as { action: string; result: HeadlessResult }; await registerInstance(result, driver.name); printSummary(result); + // The headless pipeline's subprocesses can keep the event loop alive. + process.exit(0); } else { // Ctrl-C or error — clean up VM and project dir const vmConfig = configToVMConfig(config); From d0364e90913f23a9a7112906cff0aec017a50c9c Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:20:24 +0100 Subject: [PATCH 11/13] fix: include instance name in next-steps commands Both the TUI completion screen and the post-alt-screen summary now template the instance name into shell, status, oc dashboard, and oc onboard commands so they work regardless of context. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/create.ts | 9 +++++---- .../cli/src/components/completion-screen.tsx | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 8caeed3..a07403f 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -168,11 +168,12 @@ function printSummary(result: HeadlessResult): void { } console.log(` Config ${result.projectDir}/clawctl.json`); console.log(); - console.log(` ${BIN_NAME} shell Enter the VM`); - console.log(` ${BIN_NAME} oc dashboard Open the dashboard`); - console.log(` ${BIN_NAME} status Check instance health`); + const n = result.name; + console.log(` ${BIN_NAME} shell ${n} Enter the VM`); + console.log(` ${BIN_NAME} oc dashboard -i ${n} Open the dashboard`); + console.log(` ${BIN_NAME} status ${n} Check instance health`); if (!result.providerType) { - console.log(` ${BIN_NAME} oc onboard Configure a provider`); + console.log(` ${BIN_NAME} oc onboard -i ${n} Configure a provider`); } console.log(); } diff --git a/packages/cli/src/components/completion-screen.tsx b/packages/cli/src/components/completion-screen.tsx index 5b71328..698a954 100644 --- a/packages/cli/src/components/completion-screen.tsx +++ b/packages/cli/src/components/completion-screen.tsx @@ -47,20 +47,32 @@ export function CompletionScreen({ result }: CompletionScreenProps) { Next steps: {" "} - {BIN_NAME} shell Enter the VM + + {BIN_NAME} shell {result.name} + {" "} + Enter the VM {" "} - {BIN_NAME} oc dashboard Open the dashboard + + {BIN_NAME} oc dashboard -i {result.name} + {" "} + Open the dashboard {" "} - {BIN_NAME} status Check instance health + + {BIN_NAME} status {result.name} + {" "} + Check instance health {!result.providerType && ( {" "} - {BIN_NAME} oc onboard Configure a provider + + {BIN_NAME} oc onboard -i {result.name} + {" "} + Configure a provider )} From 5f44774b74966d7bf4f80770fa8da5491a6d1277 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:23:13 +0100 Subject: [PATCH 12/13] fix: correct flag position in oc subcommands The -i flag must come before the subcommand: clawctl oc -i sam dashboard (not oc dashboard -i sam) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/create.ts | 4 ++-- packages/cli/src/components/completion-screen.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index a07403f..70f1905 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -170,10 +170,10 @@ function printSummary(result: HeadlessResult): void { console.log(); const n = result.name; console.log(` ${BIN_NAME} shell ${n} Enter the VM`); - console.log(` ${BIN_NAME} oc dashboard -i ${n} Open the dashboard`); + console.log(` ${BIN_NAME} oc -i ${n} dashboard Open the dashboard`); console.log(` ${BIN_NAME} status ${n} Check instance health`); if (!result.providerType) { - console.log(` ${BIN_NAME} oc onboard -i ${n} Configure a provider`); + console.log(` ${BIN_NAME} oc -i ${n} onboard Configure a provider`); } console.log(); } diff --git a/packages/cli/src/components/completion-screen.tsx b/packages/cli/src/components/completion-screen.tsx index 698a954..31a05d2 100644 --- a/packages/cli/src/components/completion-screen.tsx +++ b/packages/cli/src/components/completion-screen.tsx @@ -55,7 +55,7 @@ export function CompletionScreen({ result }: CompletionScreenProps) { {" "} - {BIN_NAME} oc dashboard -i {result.name} + {BIN_NAME} oc -i {result.name} dashboard {" "} Open the dashboard @@ -70,7 +70,7 @@ export function CompletionScreen({ result }: CompletionScreenProps) { {" "} - {BIN_NAME} oc onboard -i {result.name} + {BIN_NAME} oc -i {result.name} onboard {" "} Configure a provider From 143a1c266a6d46c636eb31805c4bdf3721940239 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 11:24:24 +0100 Subject: [PATCH 13/13] fix: suggest oc tui instead of oc dashboard in next steps The dashboard URL is already printed above. oc tui opens an interactive chat with the agent, which is more useful right after setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/create.ts | 2 +- packages/cli/src/components/completion-screen.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 70f1905..75e1434 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -170,7 +170,7 @@ function printSummary(result: HeadlessResult): void { console.log(); const n = result.name; console.log(` ${BIN_NAME} shell ${n} Enter the VM`); - console.log(` ${BIN_NAME} oc -i ${n} dashboard Open the dashboard`); + console.log(` ${BIN_NAME} oc -i ${n} tui Chat with your agent`); console.log(` ${BIN_NAME} status ${n} Check instance health`); if (!result.providerType) { console.log(` ${BIN_NAME} oc -i ${n} onboard Configure a provider`); diff --git a/packages/cli/src/components/completion-screen.tsx b/packages/cli/src/components/completion-screen.tsx index 31a05d2..7439e15 100644 --- a/packages/cli/src/components/completion-screen.tsx +++ b/packages/cli/src/components/completion-screen.tsx @@ -55,9 +55,9 @@ export function CompletionScreen({ result }: CompletionScreenProps) { {" "} - {BIN_NAME} oc -i {result.name} dashboard + {BIN_NAME} oc -i {result.name} tui {" "} - Open the dashboard + Chat with your agent {" "}