Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ provisioning, and — optionally — credential setup and OpenClaw onboarding.
| Command | Description |
| ------------------------------------------ | ------------------------------------------------- |
| `clawctl create` | Interactive wizard |
| `clawctl create --config <path>` | Headless mode (config-file-driven) |
| `clawctl create --config <path>` | Config-driven with TUI progress |
| `clawctl create --config <path> --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 |
Expand Down
58 changes: 39 additions & 19 deletions docs/headless-mode.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,44 @@
# Headless Mode
# Config-Driven Mode

`clawctl create --config <path>` runs the full provisioning pipeline without
interactive prompts. Use it for CI/CD, scripted setups, or reproducible team
onboarding.
`clawctl create --config <path>` 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 <path>` | TUI progress (stages, steps, logs) |
| `clawctl create --config <path> --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.

Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions packages/cli/bin/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,11 +42,14 @@ const program = new Command()
program
.command("create")
.description("Create a new OpenClaw instance")
.option("--config <path>", "Config file for headless mode")
.action(async (opts: { config?: string }) => {
.option("--config <path>", "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);
}
Expand Down
234 changes: 118 additions & 116 deletions packages/cli/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,152 +1,154 @@
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 { useTerminalSize } from "./hooks/use-terminal-size.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";

interface AppProps {
export interface AppResult {
action: "created";
result: HeadlessResult;
}

// -- ProvisionApp: config-driven TUI (skips wizard, goes straight to provision) --

interface ProvisionAppProps {
driver: VMDriver;
/** Mutable ref set when VM creation starts, so the caller can clean up on interrupt. */
creationTarget?: CleanupTarget;
config: InstanceConfig;
}

export function App({ driver, creationTarget }: AppProps) {
const [step, setStep] = useState<WizardStep>("welcome");
export function ProvisionApp({ driver, config }: ProvisionAppProps) {
const { exit } = useApp();
const { verbose } = useVerboseMode();
const [prereqs, setPrereqs] = useState<PrereqStatus>({
isMacOS: false,
isArm64: false,
hasHomebrew: false,
hasVMBackend: false,
});
const [config, setConfig] = useState<VMConfig>({
projectDir: "",
vmName: "",
cpus: 4,
memory: "8GiB",
disk: "50GiB",
});
const [credentialConfig, setCredentialConfig] = useState<CredentialConfig>({});
const [onboardSkipped, setOnboardSkipped] = useState(false);

const showHint = PROCESS_STEPS.includes(step);
const [phase, setPhase] = useState<"provision" | "done" | "error">("provision");
const [result, setResult] = useState<HeadlessResult | null>(null);
const [error, setError] = useState<string | null>(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 (
<VerboseContext.Provider value={verbose}>
<Box flexDirection="column">
{step === "welcome" && (
<Welcome
<Box flexDirection="column" height={rows}>
{phase === "provision" && (
<ProvisionMonitor
driver={driver}
onComplete={(p) => {
setPrereqs(p);
if (!p.isMacOS || !p.hasHomebrew) {
return;
}
setStep(p.hasVMBackend ? "configure" : "host-setup");
config={config}
onComplete={(res) => {
setResult(res);
setPhase("done");
}}
/>
)}

{step === "host-setup" && (
<HostSetup
driver={driver}
prereqs={prereqs}
onComplete={(updated) => {
setPrereqs(updated);
setStep("configure");
onError={(err) => {
setError(err.message);
setPhase("error");
}}
/>
)}

{step === "configure" && (
<Configure
onComplete={(c) => {
setConfig(c);
if (creationTarget) {
creationTarget.vmName = c.vmName;
creationTarget.projectDir = c.projectDir;
}
setStep("credentials");
}}
/>
)}
{phase === "done" && result && <CompletionScreen result={result} />}

{step === "credentials" && (
<Credentials
onComplete={(creds) => {
setCredentialConfig(creds);
setStep("create-vm");
}}
/>
{phase === "error" && (
<Box flexDirection="column" marginTop={1}>
<Text color="red" bold>
{"\u2717"} Provisioning failed
</Text>
<Box marginLeft={2}>
<Text color="red">{error}</Text>
</Box>
<Box flexGrow={1} />
</Box>
)}
</Box>
</VerboseContext.Provider>
);
}

{step === "create-vm" && (
<CreateVM
driver={driver}
config={config}
provisionFeatures={{
onePassword: !!credentialConfig.opToken,
tailscale: !!credentialConfig.tailscaleAuthKey,
}}
onComplete={() => setStep("provision")}
/>
)}
// -- App: full interactive wizard (prereqs → config → provision → done) --

{step === "provision" && (
<ProvisionStatus
driver={driver}
config={config}
onComplete={() => setStep("credential-setup")}
/>
interface AppProps {
driver: VMDriver;
onProvisionStart?: (config: InstanceConfig) => void;
}

export function App({ driver, onProvisionStart }: AppProps) {
const { exit } = useApp();
const [phase, setPhase] = useState<AppPhase>("prereqs");
const { verbose } = useVerboseMode();
const [config, setConfig] = useState<InstanceConfig | null>(null);
const [result, setResult] = useState<HeadlessResult | null>(null);
const [error, setError] = useState<string | null>(null);
const exited = useRef(false);
const { rows } = useTerminalSize();

// 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 (
<VerboseContext.Provider value={verbose}>
<Box flexDirection="column" height={rows}>
{phase === "prereqs" && (
<PrereqCheck driver={driver} onComplete={() => setPhase("config")} />
)}

{step === "credential-setup" && (
<CredentialSetup
driver={driver}
config={config}
credentialConfig={credentialConfig}
onComplete={(creds) => {
setCredentialConfig(creds);
setStep("onboard");
{phase === "config" && (
<ConfigBuilder
onComplete={(cfg) => {
setConfig(cfg);
onProvisionStart?.(cfg);
setPhase("provision");
}}
/>
)}

{step === "onboard" && (
<Onboard
{phase === "provision" && config && (
<ProvisionMonitor
driver={driver}
config={config}
tailscaleMode={credentialConfig.tailscaleMode}
onComplete={(skipped) => {
setOnboardSkipped(skipped);
setStep("finish");
onComplete={(res) => {
setResult(res);
setPhase("done");
}}
onError={(err) => {
setError(err.message);
setPhase("error");
}}
/>
)}

{step === "finish" && (
<Finish
config={config}
onboardSkipped={onboardSkipped}
tailscaleMode={credentialConfig.tailscaleMode}
/>
)}
{phase === "done" && result && <CompletionScreen result={result} />}

{showHint && (
<Box marginTop={1} marginLeft={2}>
<Text dimColor>Press [v] to {verbose ? "hide" : "show"} process logs</Text>
{phase === "error" && (
<Box flexDirection="column" marginTop={1}>
<Text color="red" bold>
{"\u2717"} Provisioning failed
</Text>
<Box marginLeft={2}>
<Text color="red">{error}</Text>
</Box>
<Box flexGrow={1} />
</Box>
)}
</Box>
Expand Down
Loading
Loading