fix: the first hour of an install tells the truth (#1065)

Four first-hour fixes from a first install on a fresh Mac. rig setup --providers claude|codex|both leaves an unchosen provider uninstalled and reports it Not selected; a chosen provider's failure still fails, and setup without the option is unchanged. A first rig daemon start no longer prints tmux's "error connecting" line. Setup's next steps say starting OpenRig and handing the person to the operator are part of the install. The operator's first copy no longer stops for an approval prompt.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-10 18:58:25 -07:00
1 parent e8f32eed6b
commit 4058495cac
10 files changed
+209 -57

No files matched your search

+2 -2
View File
@@ -42,7 +42,7 @@ To install with Bun instead, run `bun add -g @openrig/cli`. OpenRig still runs o
npm 11 and later can skip that postinstall script and print `npm warn install-scripts` naming `@openrig/cli`. That's expected: the CLI still works, and only the Node.js and SQLite check was skipped. To run it yourself, use `node "$(npm root -g)/@openrig/cli/scripts/check-abi.mjs"`; `rig doctor` checks the Node.js version only.
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup; applying `rig setup` checks both harnesses and installs a missing one, plus Herdr unless declined with `--no-herdr`. On macOS, the installing agent offers Ghostty once. Setup leaves an existing cmux installation alone. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup; applying `rig setup` checks both harnesses and installs a missing one, plus Herdr unless declined with `--no-herdr`. Pass the choice with `--providers claude` or `--providers codex` so the other reads not selected instead of failed. The one-command install above doesn't know the choice, so it installs and checks both; for one provider, go step by step and run `rig setup --providers <choice>`. On macOS, the installing agent offers Ghostty once. Setup leaves an existing cmux installation alone. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
A team seat with no permission policy, per-seat choice or (for Codex) named profile launches with OpenRig's [team default](docs/reference/rig-spec.md#team-launch-defaults): Claude runs ordinary `rig` commands, project reads and common tests without prompting, while lifecycle commands such as `rig up` and `rig down` still ask; Codex also gets the OpenRig workspace and its pod's state directory as writable directories. Before launching, your agent recommends keeping that default, and offers to remember selected OpenRig commands in your native settings only if you want that, at personal project scope unless you explicitly choose user-wide sessions. It is not global YOLO or permission to invent work. On Yes, the agent [adds and verifies native rules](docs/reference/getting-started.md#have-your-agent-configure-permissions); No or no answer keeps the team default and leaves settings unchanged. An existing explicit choice is reused. Say “Undo the OpenRig command allowances added by this setup” to remove only its additions.
@@ -447,7 +447,7 @@ Before setup or managed launch, review [what OpenRig changes on your machine](#w
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice. Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Then start the daemon if it is stopped, open the OpenRig TUI and operator for them (say so, then run `rig terminal open saved:kernel --window --json`; no commands for the person to copy), and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice, and pass it to setup (`rig setup --providers claude`, `codex` or `both`). Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Starting OpenRig and opening its view are the last steps of installing, not extras, so do them without stopping to ask whether to go on: start the daemon if it is stopped, open the OpenRig TUI and operator for them (say so, then run `rig terminal open saved:kernel --window --json`; no commands for the person to copy), and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
## Comparison with Claude Managed Agents
+1 -1
View File
@@ -818,7 +818,7 @@ Root: `rig`; declared option: `-V, --version`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--policy <name>`<br>`--spec <path>` |
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--providers <list>`<br>`--policy <name>`<br>`--spec <path>` |
### restore-check
+4 -2
View File
@@ -137,7 +137,9 @@ The other provider's CLI/login is optional. Herdr is installed by default unless
you decline it; cmux remains optional. Do not copy credentials
or start repeated sign-in attempts. Recheck the selected login after the user
completes it. `rig setup --dry-run` previews the broader setup; applying
`rig setup` checks **both** harnesses and installs a missing one with npm; on
`rig setup` checks **both** harnesses and installs a missing one with npm, unless
you pass the person's choice: `rig setup --providers claude` (or `codex`) leaves
the other out, so it reads not selected instead of failed. On
macOS it uses an existing Homebrew (it does not install Homebrew) to install a
missing tmux (elsewhere tmux is checked, not installed). Herdr installs on
macOS and Linux; pass `--no-herdr` to decline it. Setup also
@@ -198,7 +200,7 @@ leaves existing cmux settings alone and does not open a view during setup.
On a Mac, the installing agent also asks once, "Install Ghostty for the OpenRig
view?" On Yes it reruns setup with `--ghostty`, preserving earlier choices such
as `--no-herdr`. This installs the
as `--no-herdr` and `--providers`. This installs the
[documented Homebrew cask](https://ghostty.org/docs/install/binary) and checks for
the app. On No it uses `--no-ghostty`; Terminal.app remains available. Plain setup
reports this offer without installing Ghostty or waiting for input. The person
+2 -3
View File
@@ -184,8 +184,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight } = await import("../system-preflight.js");
const { execSync } = await import("node:child_process");
const { SystemPreflight, quietPreflightExec } = await import("../system-preflight.js");
const configStore = new ConfigStore();
const config = configStore.resolve();
const effectivePort = opts.port ? parseInt(opts.port, 10) : config.daemon.port;
@@ -210,7 +209,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// Run preflight before starting
const preflight = new SystemPreflight({
exec: async (cmd) => execSync(cmd, { encoding: "utf-8" }),
exec: quietPreflightExec,
configStore,
getDaemonStatus: () => getDaemonStatus(getDeps()),
openrigHome: OPENRIG_DIR,
+98 -46
View File
@@ -183,7 +183,7 @@ function installGhostty(deps: SetupDeps, platform: NodeJS.Platform, choice?: boo
if (choice !== true) return {
id, status: "skipped",
message: "Installing agent: ask once, 'Install Ghostty for the OpenRig view?'",
fixHint: "On Yes, rerun rig setup with --ghostty, preserving earlier choices such as --no-herdr. On No, use --no-ghostty; Terminal.app remains available. The person types no command.",
fixHint: "On Yes, rerun rig setup with --ghostty, preserving earlier choices such as --no-herdr and --providers. On No, use --no-ghostty; Terminal.app remains available. The person types no command.",
};
try {
installCommand(deps, "brew install --cask ghostty");
@@ -289,15 +289,48 @@ export function recordPermissionPolicyStep(deps: SetupDeps, choice: string, spec
};
}
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; policy?: string; specPath?: string; herdr?: boolean; ghostty?: boolean; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
export type SetupProvider = "claude" | "codex";
const PROVIDER_LABELS: Record<SetupProvider, string> = { claude: "Claude Code", codex: "Codex" };
/** `--providers`: the person's choice, comma-separated (`claude`, `codex`, `claude,codex` or `both`). */
export function parseSetupProviders(value: string): SetupProvider[] | { error: string } {
const names = value.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
const chosen = new Set<SetupProvider>();
for (const name of names) {
if (name === "both") { chosen.add("claude"); chosen.add("codex"); }
else if (name === "claude" || name === "codex") chosen.add(name);
else return { error: `Unknown provider '${name}' in --providers: use claude, codex, or both.` };
}
if (chosen.size === 0) return { error: "--providers needs claude, codex, or both." };
return [...chosen];
}
/** A provider the person didn't choose is left out: not installed, not checked, and never failed. */
function notSelectedSteps(provider: SetupProvider, chosen: readonly SetupProvider[]): SetupStep[] {
const label = PROVIDER_LABELS[provider];
const choice = chosen.map((p) => PROVIDER_LABELS[p]).join(" and ");
return [
{ id: `${provider}_install`, status: "skipped", message: `Not selected: the person chose ${choice} (--providers), so ${label} is not installed here.` },
{ id: `${provider}_auth`, status: "skipped", message: `Not selected: ${label} sign-in is not needed for this choice.` },
];
}
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; policy?: string; specPath?: string; herdr?: boolean; ghostty?: boolean; providers?: readonly SetupProvider[]; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
const profile = opts.full ? "full" : "core";
const platform = deps.platform ?? process.platform;
const runtimeConfig = [...BASE_RUNTIME_CONFIG_DISCLOSURE];
const stepIds = opts.full ? [...CORE_STEP_IDS, ...FULL_EXTRA_STEP_IDS] : [...CORE_STEP_IDS];
const steps: SetupStep[] = [];
// No --providers: both are checked, as before. With it, only the chosen ones.
const selected = (provider: SetupProvider) => !opts.providers || opts.providers.includes(provider);
if (opts.dryRun) {
for (const id of stepIds) {
const provider = (["claude", "codex"] as const).find((p) => id === `${p}_install` || id === `${p}_auth`);
if (provider && !selected(provider)) {
steps.push(notSelectedSteps(provider, opts.providers!).find((step) => step.id === id)!);
continue;
}
const platformSkip = id === "herdr_install" && opts.herdr === false ? "Skipped: herdr installation declined (--no-herdr)."
: id === "herdr_install" && !["darwin", "linux"].includes(platform) ? "Automatic herdr installation is supported on macOS and Linux."
: id === "ghostty_install" ? (platform !== "darwin" ? "The Ghostty offer is for macOS."
@@ -371,56 +404,62 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
steps.push(installGhostty(deps, platform, opts.ghostty));
// 4. Claude Code runtime
let claudeInstalled = false;
try {
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "pass", message: "Claude Code available." });
} catch {
if (!selected("claude")) steps.push(...notSelectedSteps("claude", opts.providers!));
else {
let claudeInstalled = false;
try {
installCommand(deps, "npm install -g @anthropic-ai/claude-code");
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "applied", message: "Installed Claude Code with npm." });
} catch (err) {
steps.push({
id: "claude_install",
status: "fail",
message: `Failed to install Claude Code: ${(err as Error).message}`,
reason: "Claude Code seats need the Claude CLI; a Codex-only project can use its own runtime readiness result.",
fixHint: "Install Claude Code with `npm install -g @anthropic-ai/claude-code`.",
});
steps.push({ id: "claude_install", status: "pass", message: "Claude Code available." });
} catch {
try {
installCommand(deps, "npm install -g @anthropic-ai/claude-code");
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "applied", message: "Installed Claude Code with npm." });
} catch (err) {
steps.push({
id: "claude_install",
status: "fail",
message: `Failed to install Claude Code: ${(err as Error).message}`,
reason: "Claude Code seats need the Claude CLI; a Codex-only project can use its own runtime readiness result.",
fixHint: "Install Claude Code with `npm install -g @anthropic-ai/claude-code`.",
});
}
}
}
const { name: claudeName, fix: claudeFix, ...claudeAuth } = checkClaudeAuth(deps, claudeInstalled);
steps.push({ id: claudeName, ...claudeAuth, ...(claudeFix ? { fixHint: claudeFix } : {}) });
const { name: claudeName, fix: claudeFix, ...claudeAuth } = checkClaudeAuth(deps, claudeInstalled);
steps.push({ id: claudeName, ...claudeAuth, ...(claudeFix ? { fixHint: claudeFix } : {}) });
}
// 5. Codex runtime
let codexInstalled = false;
try {
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "pass", message: "Codex available." });
} catch {
if (!selected("codex")) steps.push(...notSelectedSteps("codex", opts.providers!));
else {
let codexInstalled = false;
try {
installCommand(deps, "npm install -g @openai/codex");
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "applied", message: "Installed Codex with npm." });
} catch (err) {
steps.push({
id: "codex_install",
status: "fail",
message: `Failed to install Codex: ${(err as Error).message}`,
reason: "Codex seats need the Codex CLI installed on this machine.",
fixHint: "Install Codex with `npm install -g @openai/codex`.",
});
steps.push({ id: "codex_install", status: "pass", message: "Codex available." });
} catch {
try {
installCommand(deps, "npm install -g @openai/codex");
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "applied", message: "Installed Codex with npm." });
} catch (err) {
steps.push({
id: "codex_install",
status: "fail",
message: `Failed to install Codex: ${(err as Error).message}`,
reason: "Codex seats need the Codex CLI installed on this machine.",
fixHint: "Install Codex with `npm install -g @openai/codex`.",
});
}
}
}
const { name: codexName, fix: codexFix, ...codexAuth } = checkCodexAuth(deps, codexInstalled);
steps.push({ id: codexName, ...codexAuth, ...(codexFix ? { fixHint: codexFix } : {}) });
const { name: codexName, fix: codexFix, ...codexAuth } = checkCodexAuth(deps, codexInstalled);
steps.push({ id: codexName, ...codexAuth, ...(codexFix ? { fixHint: codexFix } : {}) });
}
// 6. tmux config
const TMUX_CONF = `${process.env["HOME"] ?? "~"}/.tmux.conf`;
@@ -554,9 +593,11 @@ function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
*/
export function goldenPathNextSteps(): string[] {
return [
"Next steps (guidance only; full reference: rig context get reference/getting-started.md):",
"Finish installing (full reference: rig context get reference/getting-started.md). Once setup has applied, steps 2 to 5 are",
" part of the install, not extras: do them without stopping to ask whether to go on",
" 1. Check only selected logins: claude auth status or codex login status; one working provider is enough",
" 2. rig daemon start If stopped; a fresh instance also starts the kernel, including operator and advisor",
" 2. rig daemon start If stopped. Starting OpenRig finishes the install; it is not a step beyond setup",
" A fresh instance also starts the kernel, including operator and advisor",
" The kernel is part of installation. Keep it for normal setup; --no-kernel is for automation or when requested",
" 3. rig status Read the kernel boot state; rig ps --nodes --rig kernel checks its seats",
" Started is not ready. The view may open while agents finish starting; report their actual state",
@@ -569,7 +610,7 @@ export function goldenPathNextSteps(): string[] {
" Herdr needs a terminal the person can see; switching the shared TUI to :terminals does not open one",
" No, SSH and headless use are fine background outcomes; do not report an unseen window as opened",
" On a desktop, no copying or typing for the person; check the result notes, not just CLI success",
" On a Mac, offer Ghostty once: on Yes run rig setup --ghostty; preserve earlier choices such as --no-herdr",
" On a Mac, offer Ghostty once: on Yes run rig setup --ghostty; preserve earlier choices such as --no-herdr and --providers",
" On No use --no-ghostty; Terminal.app remains available",
" Setup installs herdr by default; use --no-herdr if the person declines. Plain tmux remains available",
" Only if the window cannot open: rig tui --shared is the dashboard-only fallback, not the operator's conversation",
@@ -644,12 +685,19 @@ export function setupCommand(depsOverride?: SetupDeps): Command {
.option("--no-herdr", "Skip the default herdr installation; the OpenRig view can use plain tmux")
.option("--ghostty", "Install Ghostty on macOS after the person accepts the offer")
.option("--no-ghostty", "Decline Ghostty; Terminal.app remains available on macOS")
.option("--providers <list>", "The person's provider choice: claude, codex, or both. One not chosen is skipped (not installed or checked), never failed")
.option("--policy <name>", `Record a deliberate permission-policy choice into an existing spec (${POLICY_CHOICES.join("|")})`)
.option("--spec <path>", "Existing rig spec (file or directory) to record the --policy choice into")
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean; herdr?: boolean; ghostty?: boolean; policy?: string; spec?: string }) => {
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean; herdr?: boolean; ghostty?: boolean; providers?: string; policy?: string; spec?: string }) => {
const providers = opts.providers === undefined ? undefined : parseSetupProviders(opts.providers);
if (providers && "error" in providers) {
console.error(providers.error);
process.exitCode = 1;
return;
}
const deps = depsOverride ?? defaultDeps();
const doctorDeps = opts.dryRun ? undefined : buildDefaultDoctorDeps(deps);
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, policy: opts.policy, specPath: opts.spec, herdr: opts.herdr, ghostty: opts.ghostty, doctorDeps });
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, policy: opts.policy, specPath: opts.spec, herdr: opts.herdr, ghostty: opts.ghostty, providers, doctorDeps });
if (opts.json) {
console.log(JSON.stringify({ ...result, nextSteps: goldenPathNextSteps() }, null, 2));
@@ -671,6 +719,10 @@ export function setupCommand(depsOverride?: SetupDeps): Command {
if (step.reason) console.log(` Why: ${step.reason}`);
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
if (!providers) {
console.log("\n Providers: setup installs and checks both Claude Code and Codex. If the person chose one, pass it,");
console.log(" for example rig setup --providers claude; the other then reads not selected instead of failed.");
}
// OPR.0.3.3.04.2 (AC-1): the canonical ordered golden path. `rig setup` is
// the primary surface for the new-operator sequence (status/doctor only
@@ -684,7 +736,7 @@ export function setupCommand(depsOverride?: SetupDeps): Command {
console.log(` ${step.id}: ${step.message}`);
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
console.log("Only the harnesses selected for your project need a login; an unused harness does not.");
console.log("Only the harnesses selected for your project need a login; an unused harness does not (rerun with --providers to leave it out).");
console.log("Run `rig doctor` to recheck the system and provider authentication.");
}
// Keep the conversation route available even after a dry run or incomplete setup,
+9
View File
@@ -1,4 +1,5 @@
import net from "node:net";
import { execSync } from "node:child_process";
import { accessSync, mkdirSync, constants } from "node:fs";
import { dirname } from "node:path";
import type { ConfigStore, RiggedConfig } from "./config-store.js";
@@ -21,6 +22,14 @@ export interface PreflightResult {
checks: PreflightCheck[];
}
/** Run a preflight probe with its stderr captured, not echoed. Without `stdio`, `execSync` also copies the child's
* stderr to the terminal, so a first `rig daemon start` printed tmux's "error connecting to … (No such file or
* directory)" for the ordinary no-server case. The text still reaches the probe in the thrown error, so a real tmux
* failure is still classified and reported. */
export async function quietPreflightExec(cmd: string): Promise<string> {
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
}
interface PreflightDeps {
exec: (cmd: string) => Promise<string>;
configStore: ConfigStore;
@@ -0,0 +1,28 @@
// A first `rig daemon start` on a machine with no tmux server yet printed tmux's "error connecting to …" line: the
// preflight's execSync had no stdio, so Node copied the probe's stderr to the terminal. The probe still needs that
// text to tell "no server yet" from a broken server, so it must arrive in the thrown error and nowhere else.
import { describe, it, expect, vi, afterEach } from "vitest";
import { quietPreflightExec } from "../src/system-preflight.js";
import { probeTmuxControlAsync } from "../src/tmux-health.js";
const node = JSON.stringify(process.execPath);
const failWith = (text: string) => `${node} -e "process.stderr.write(${JSON.stringify(text).replaceAll('"', '\\"')}); process.exit(1)"`;
describe("quietPreflightExec", () => {
afterEach(() => { vi.restoreAllMocks(); });
it("keeps a failing probe's stderr off the terminal and in the error", async () => {
const writes: string[] = [];
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { writes.push(String(chunk)); return true; });
const noServer = "error connecting to /private/tmp/tmux-503/default (No such file or directory)";
await expect(quietPreflightExec(failWith(noServer))).rejects.toThrow(/error connecting to/);
expect(writes.join("")).not.toContain("error connecting to");
});
it("still lets the tmux probe tell no server yet from a broken server", async () => {
const run = (stderr: string) => probeTmuxControlAsync(async (cmd) =>
cmd === "tmux -V" ? "tmux 3.4" : quietPreflightExec(failWith(stderr)));
expect((await run("error connecting to /tmp/tmux-1/default (No such file or directory)")).code).toBe("no_server");
expect(await run("server exited unexpectedly")).toMatchObject({ code: "unhealthy", available: false, detail: expect.stringContaining("server exited unexpectedly") });
});
});
+59 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { createProgram } from "../src/index.js";
import { Command } from "commander";
import { parse as parseYaml } from "yaml";
import { setupCommand, runSetup, goldenPathNextSteps, permissionPolicyMenuLines, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
import { setupCommand, runSetup, goldenPathNextSteps, permissionPolicyMenuLines, parseSetupProviders, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
import type { DoctorDeps } from "../src/commands/doctor.js";
function makeDeps(overrides?: Partial<SetupDeps>): SetupDeps {
@@ -884,3 +884,61 @@ describe("codex_auth — provider-aware Codex readiness (#194)", () => {
expect(codexAuthStep(result)?.status).toBe("skipped");
});
});
describe("--providers: a provider the person didn't choose reads not selected, never failed (0.6.9 F3)", () => {
const base = makeDeps().exec;
/** A machine with Claude Code signed in and Codex neither installed nor installable. */
function claudeOnlyMachine(execs: string[], claudeAuth = true): SetupDeps {
return makeDeps({
exec: (cmd: string) => {
execs.push(cmd);
if (cmd.startsWith("codex") || cmd.includes("@openai/codex")) throw new Error("codex unavailable");
if (cmd === "claude auth status" && !claudeAuth) throw new Error("not logged in");
return base(cmd);
},
});
}
it("leaves Codex out when the person chose Claude: not installed, not checked, and setup is ready", async () => {
const execs: string[] = [];
const result = await runSetup(claudeOnlyMachine(execs), { providers: ["claude"] });
expect(result.steps.find((s) => s.id === "codex_install")).toMatchObject({ status: "skipped", message: expect.stringContaining("Not selected") });
expect(result.steps.find((s) => s.id === "codex_auth")).toMatchObject({ status: "skipped", message: expect.stringContaining("Not selected") });
expect(execs.filter((cmd) => cmd.includes("codex"))).toEqual([]);
expect(result.ready).toBe(true);
});
it("without --providers still installs and checks both, so the same machine reads failed for Codex", async () => {
const result = await runSetup(claudeOnlyMachine([]), {});
expect(result.steps.find((s) => s.id === "codex_install")?.status).toBe("fail");
expect(result.ready).toBe(false);
});
it("still reads failed for a provider the person chose", async () => {
const result = await runSetup(claudeOnlyMachine([], false), { providers: ["claude"] });
expect(result.steps.find((s) => s.id === "claude_auth")?.status).toBe("fail");
expect(result.ready).toBe(false);
});
it("names a provider left out in the dry-run plan", async () => {
const result = await runSetup(makeDeps(), { dryRun: true, providers: ["codex"] });
expect(result.steps.find((s) => s.id === "claude_install")?.message).toContain("Not selected");
expect(result.steps.find((s) => s.id === "codex_install")?.message).toContain("would be attempted");
});
it("parses the person's choice and rejects an unknown provider", async () => {
expect(parseSetupProviders("claude")).toEqual(["claude"]);
expect(parseSetupProviders("both")).toEqual(["claude", "codex"]);
expect(parseSetupProviders(" Codex , claude ")).toEqual(["codex", "claude"]);
expect(parseSetupProviders("gemini")).toEqual({ error: expect.stringContaining("Unknown provider 'gemini'") });
expect(parseSetupProviders(",")).toEqual({ error: expect.stringContaining("needs claude, codex, or both") });
const errors: string[] = [];
vi.spyOn(console, "error").mockImplementation((...args) => { errors.push(args.map(String).join(" ")); });
process.exitCode = undefined;
await new Command().addCommand(setupCommand(makeDeps())).parseAsync(["node", "rig", "setup", "--providers", "gemini"]);
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("Unknown provider 'gemini'");
process.exitCode = undefined;
vi.restoreAllMocks();
});
});
@@ -14,7 +14,7 @@ vi.mock("../src/daemon-lifecycle.js", async (original) => {
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
return { ...actual, getDaemonStatus: vi.fn(async () => ({ state: "stopped" })), startDaemon: vi.fn(async () => { throw new Error("startup options captured"); }) };
});
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { async run() { return { ready: true, checks: [] }; } } }));
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { async run() { return { ready: true, checks: [] }; } }, quietPreflightExec: async () => "" }));
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "transcript-startup-"));
@@ -165,7 +165,11 @@ configurations, as below; do not take the pin from a cached page summary.
copy in `workspace.specs_root` or `~/.openrig/specs` would shadow the
shipped team by name. Copying the folder carries every file the spec
names relative to itself: its culture file, docs, startup files at rig,
pod and member level, services and policy files. Then, in the copy's
pod and member level, services and policy files. In Claude Code, make the
copy with your Read and Write tools, one file at a time (Write creates the
folders), not with `cp -R`: Claude Code asks for approval before `cp` or
`mv` with any flag, even though your launch allows them, and that prompt
stops the person on their first team. Then, in the copy's
`rig.yaml`, keep `name:`, change each member's `runtime` to one they have
and drop that member's `model:` pin. Any reference that climbs out of the
folder (`local:../…` agent refs, any `../` path) still points at the old