feat(cli): spawn export — capture a claude session into a github repo (#3377)

* feat(cli): spawn export — capture a claude session into a github repo

Adds `spawn export [name|id]` as a top-level subcommand. Captures a
running claude spawn into a redistributable github repo whose README
contains the canonical re-spawn command — the symmetric inverse of
`--repo`.

What gets exported:
- `~/project/`           working tree (with aggressive .gitignore)
- `~/.claude/`           sanitized agent system dir: skills, commands,
                         hooks, CLAUDE.md, AGENTS.md, settings.json
                         (with token-shaped fields stripped)
- `spawn.md`             generated re-spawn metadata
- `README.md`            generated; renders a re-auth checklist on github

The export runs over the existing SshRunner. v1 is claude-only; non-claude
agents return a clear "not yet supported" error. Bumps CLI 1.0.27 -> 1.1.0
because this is a real new surface, not a fix.

Followups (not in v1):
- claude introspects its own session (MCP servers, OAuth providers) and
  writes them into spawn.md's setup steps
- local cloud target uses a direct branch (currently routed through SSH)
- in-session `:export` slash command

* fix(cli): use patch bump (1.0.28) to match team versioning cadence

The update-check tests hardcode 1.0.x patch-bump scenarios as test fixtures
(e.g. mocking "latest" as 1.0.99 to verify patch auto-install policy). A
1.1.0 bump made those mocked versions look like downgrades and failed CI.
Realign with the team's recent patch-bump cadence — every recent feature
PR has shipped under 1.0.x.

* feat(export): list picker, claude picks the slug, pre-commit secrets scan

Three changes per review feedback:

1. **Picker.** When the user has multiple exportable claude spawns and
   no positional target arg, show a clack `select` listing them. Auto-pick
   on a single match. Filter out non-claude / no-connection / deleted /
   sprite-console records up front.

2. **Claude decides the repo name.** No more slug prompt. The on-VM script
   runs `claude -p` with a name-suggestion prompt asking for a kebab-case
   project name (max 40 chars, [a-z0-9-]). Falls back to basename(~/project)
   then a timestamp slug if claude is unavailable or returns garbage.
   `gh api user --jq .login` provides the username; missing gh auth aborts
   with a structured JSON failure the CLI surfaces verbatim.

3. **Pre-commit secrets scan.** After `git add`, scan all staged files for
   known API-key shapes — Anthropic (sk-ant-api...), OpenRouter (sk-or-v1-),
   OpenAI (sk-proj-), GitHub PAT/OAuth/server (gh[ops]_), AWS (AKIA...),
   Hetzner (hcloud_), DigitalOcean (dop_v1_), and PEM private keys. Any
   match aborts the export with `{"ok":false,"error":"..."}` to the result
   file. The settings.json scrubber now recurses; previously it only
   stripped top-level + env keys.

Also expands the .gitignore deny-list with .spawnrc, .bash_history,
.aws/, .config/spawn/, .config/gcloud/, .gnupg/, *.token, *.credentials.

Bumps CLI 1.0.28 -> 1.0.29.

* feat(export): default to public visibility

Exports are share-friendly artifacts — public by default makes the
'spawn link' (the printed re-spawn command) usable by anyone the user
hands it to. Override path stays via options.visibility for callers
that need private.

Bumps CLI 1.0.29 -> 1.0.30.

* feat(export): bake --steps into the spawn link for zero-prompt respawn

The printed spawn link is now:
  spawn claude <cloud> --repo <slug> --steps <list>

Source of the steps list:
1. Parse `--steps <value>` (or `--steps=<value>`) out of the original
   record.connection.launch_cmd.
2. Fall back to 'github,auto-update,security-scan' when the launch_cmd
   doesn't carry it (older spawns, or interactive launches that didn't
   pass the flag).

The respawn consumer reads SPAWN_ENABLED_STEPS from --steps and skips
the interactive setup picker entirely, so handing someone the spawn
link is a true zero-choice replay.

Adds parseStepsFromLaunchCmd + resolveSteps helpers, both exported
from the barrel for testability. README template grows a __STEPS__
placeholder; bash sed adds it to the substitution pass.

Bumps CLI 1.0.30 -> 1.0.31.

* fix(export): safe defaults and tighter flag parsing

Review follow-ups to #3377:

- Visibility: default private; interactive "make public?" confirm when
  the caller doesn't force one. Prior default-public + one-shot `gh repo
  create --push` was a public-leak footgun when the secret regex missed.
- `parseStepsFromLaunchCmd`: anchor both regexes to start-or-whitespace
  so `--no-steps=foo` no longer over-matches and returns `foo`.
- `--exclude=.git` on the claude/{skills,commands,hooks} rsync so a
  nested git checkout inside a skill doesn't leak its history.
- Replace `record!` / `conn!` non-null assertions with explicit
  narrowing — matches the project's type-safety rule (no `as`, no `!`).
- Tests: lock in private default, the .git exclude, and the negative
  `--no-steps=` regex case (14 new expects, 32/32 pass).
- Bump CLI to 1.0.32.

---------

Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-05-01 23:45:50 -07:00 committed by GitHub
parent 83ecccf4ad
commit 3e99bdd604
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 849 additions and 1 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.27",
"version": "1.0.32",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -0,0 +1,348 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { mockClackPrompts } from "./test-helpers";
mockClackPrompts();
import type { SpawnRecord } from "../history";
import {
buildExportScript,
buildGitignore,
buildReadmeTemplate,
buildSpawnMd,
cmdExport,
parseStepsFromLaunchCmd,
resolveSteps,
} from "../commands/export";
import { parseSpawnMd } from "../shared/spawn-md";
const baseRecord: SpawnRecord = {
id: "abc-123",
agent: "claude",
cloud: "hetzner",
timestamp: "2026-05-01T00:00:00Z",
name: "demo session",
connection: {
ip: "1.2.3.4",
user: "spawn",
cloud: "hetzner",
server_id: "srv-1",
server_name: "demo-server",
},
};
let stderrSpy: ReturnType<typeof spyOn>;
let stdoutSpy: ReturnType<typeof spyOn>;
let exitSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
stderrSpy = spyOn(process.stderr, "write").mockReturnValue(true);
stdoutSpy = spyOn(process.stdout, "write").mockReturnValue(true);
exitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => {
throw new Error("__exit__");
});
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
exitSpy.mockRestore();
mock.restore();
});
// ── Pure builders ───────────────────────────────────────────────────────────
describe("buildSpawnMd", () => {
it("emits valid frontmatter that parses through parseSpawnMd", () => {
const md = buildSpawnMd(baseRecord);
const parsed = parseSpawnMd(md);
expect(parsed).not.toBeNull();
expect(parsed?.name).toBe("demo session");
expect(parsed?.description).toContain("abc-123");
});
it("falls back to a default heading when name is missing", () => {
const noName: SpawnRecord = {
...baseRecord,
name: undefined,
};
const md = buildSpawnMd(noName);
expect(md).toContain("# spawn export");
});
});
describe("buildReadmeTemplate", () => {
it("uses placeholders the bash script will substitute", () => {
const tpl = buildReadmeTemplate();
expect(tpl).toContain("__NAME__");
expect(tpl).toContain("__CLOUD__");
expect(tpl).toContain("__SLUG__");
expect(tpl).toContain("__STEPS__");
expect(tpl).toContain("spawn claude __CLOUD__ --repo __SLUG__ --steps __STEPS__");
});
it("renders a github-friendly checklist", () => {
const tpl = buildReadmeTemplate();
expect(tpl).toContain("- [ ] `gh auth login`");
expect(tpl).toContain("- [ ] Re-OAuth");
});
});
describe("buildGitignore", () => {
it("excludes node_modules, env files, and known credential paths", () => {
const gi = buildGitignore();
expect(gi).toContain("node_modules/");
expect(gi).toContain(".env");
expect(gi).toContain(".env.*");
expect(gi).toContain(".spawnrc");
expect(gi).toContain(".aws/");
expect(gi).toContain(".config/spawn/");
expect(gi).toContain(".config/gcloud/");
});
});
describe("parseStepsFromLaunchCmd", () => {
it("returns null when launch_cmd is undefined or has no --steps", () => {
expect(parseStepsFromLaunchCmd(undefined)).toBeNull();
expect(parseStepsFromLaunchCmd("spawn claude hetzner")).toBeNull();
});
it("parses space-separated --steps", () => {
expect(parseStepsFromLaunchCmd("spawn claude hetzner --steps github,browser")).toBe("github,browser");
});
it("parses --steps=value form", () => {
expect(parseStepsFromLaunchCmd("spawn claude hetzner --steps=github,auto-update")).toBe("github,auto-update");
});
it("ignores --steps inside other flags", () => {
// --no-steps shouldn't match
expect(parseStepsFromLaunchCmd("spawn claude hetzner --no-steps")).toBeNull();
});
it("does not over-match --no-steps=value", () => {
// Without word-boundary anchoring, --no-steps=foo would match and
// return "foo". The regex must only fire on the real --steps flag.
expect(parseStepsFromLaunchCmd("spawn claude hetzner --no-steps=foo")).toBeNull();
expect(parseStepsFromLaunchCmd("spawn claude hetzner --no-steps foo")).toBeNull();
});
});
describe("resolveSteps", () => {
it("returns the parsed value when launch_cmd carries --steps", () => {
const r: SpawnRecord = {
...baseRecord,
connection: {
...baseRecord.connection!,
launch_cmd: "spawn claude hetzner --steps github,reuse-api-key",
},
};
expect(resolveSteps(r)).toBe("github,reuse-api-key");
});
it("falls back to a default when launch_cmd has no --steps", () => {
expect(resolveSteps(baseRecord)).toBe("github,auto-update,security-scan");
});
});
describe("buildExportScript", () => {
const opts = {
spawnMd: "---\nname: x\n---\n",
readmeTemplate: "# __NAME__\n",
gitignore: "node_modules/\n",
cloud: "hetzner",
steps: "github,auto-update,security-scan",
visibility: "private" as const,
resultPath: "/tmp/spawn-export-result.json",
};
it("uses set -eo pipefail", () => {
expect(buildExportScript(opts)).toContain("set -eo pipefail");
});
it("rsyncs the working tree and the claude system dir", () => {
const s = buildExportScript(opts);
expect(s).toContain("rsync -a --exclude=node_modules");
expect(s).toContain('"$HOME/project/"');
expect(s).toContain('"$HOME/.claude/$d/"');
});
it("invokes claude -p to suggest the repo name", () => {
const s = buildExportScript(opts);
expect(s).toContain("claude -p");
expect(s).toContain("kebab-case");
});
it("falls back through basename(~/project) then a timestamp slug", () => {
const s = buildExportScript(opts);
expect(s).toContain('basename "$HOME/project"');
expect(s).toContain("spawn-export-$(date +%s)");
});
it("looks up the gh user and aborts if gh isn't authed", () => {
const s = buildExportScript(opts);
expect(s).toContain("gh api user --jq .login");
expect(s).toContain('"error":"gh is not authenticated');
});
it("scans staged files for known API-key patterns and aborts on hit", () => {
const s = buildExportScript(opts);
expect(s).toContain("SECRET_REGEX=");
// Verify a representative pattern from each provider family is present
expect(s).toContain("sk-or-v1-"); // OpenRouter
expect(s).toContain("sk-ant-api"); // Anthropic
expect(s).toContain("sk-proj-"); // OpenAI
expect(s).toContain("gh[ops]_"); // GitHub PAT/OAuth/server
expect(s).toContain("AKIA"); // AWS access key
expect(s).toContain("hcloud_"); // Hetzner
expect(s).toContain("dop_v1_"); // DigitalOcean
expect(s).toContain("BEGIN ([A-Z]+ )?PRIVATE KEY"); // PEM
expect(s).toContain("Possible secrets detected");
});
it("uses gh repo create with the cloud and slug from the script", () => {
const s = buildExportScript(opts);
expect(s).toContain('gh repo create "$SLUG" "$VISIBILITY_FLAG" --source=. --push');
});
it("flips to --public when visibility is public", () => {
const s = buildExportScript({
...opts,
visibility: "public",
});
expect(s).toContain("VISIBILITY_FLAG=--public");
expect(s).not.toContain("VISIBILITY_FLAG=--private");
});
it("emits --private when visibility is private (safe default)", () => {
// `opts.visibility` is "private" above; lock that in so a future default
// flip to public doesn't go unnoticed.
const s = buildExportScript(opts);
expect(s).toContain("VISIBILITY_FLAG=--private");
expect(s).not.toContain("VISIBILITY_FLAG=--public");
});
it("excludes .git when copying claude subdirs so nested checkouts don't leak", () => {
const s = buildExportScript(opts);
// The claude subdir rsync (skills/commands/hooks) targets "$HOME/.claude/$d/".
// Without --exclude=.git, a skill that happens to be a git checkout would
// ship its history in the exported repo.
expect(s).toContain('rsync -a --exclude=.git "$HOME/.claude/$d/"');
});
it("writes the result JSON to the supplied path", () => {
const s = buildExportScript({
...opts,
resultPath: "/tmp/custom.json",
});
expect(s).toContain("RESULT_PATH='/tmp/custom.json'");
expect(s).toContain('"ok":true,"slug":"%s","url":"https://github.com/%s"');
});
it("emits a structured failure result when gh isn't authed", () => {
const s = buildExportScript(opts);
expect(s).toContain('"ok":false,"error":"gh is not authenticated');
});
it("recursively scrubs nested settings.json fields, not just top-level", () => {
const s = buildExportScript(opts);
expect(s).toContain("const scrub = (obj) =>");
expect(s).toContain("scrub(parsed)");
});
it("bakes the steps list into the script and substitutes __STEPS__", () => {
const s = buildExportScript(opts);
expect(s).toContain("STEPS='github,auto-update,security-scan'");
expect(s).toContain("s|__STEPS__|$STEPS|g");
});
});
// ── cmdExport orchestration ─────────────────────────────────────────────────
describe("cmdExport", () => {
it("errors out when no exportable claude spawns exist", async () => {
await expect(
cmdExport(undefined, {
records: [],
}),
).rejects.toThrow("__exit__");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("filters out non-claude agents", async () => {
const codexRecord: SpawnRecord = {
...baseRecord,
agent: "codex",
};
await expect(
cmdExport(undefined, {
records: [
codexRecord,
],
}),
).rejects.toThrow("__exit__");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("filters out spawns without connection info", async () => {
const noConn: SpawnRecord = {
...baseRecord,
connection: undefined,
};
await expect(
cmdExport(undefined, {
records: [
noConn,
],
}),
).rejects.toThrow("__exit__");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("filters out deleted spawns", async () => {
const deleted: SpawnRecord = {
...baseRecord,
connection: {
...baseRecord.connection!,
deleted: true,
},
};
await expect(
cmdExport(undefined, {
records: [
deleted,
],
}),
).rejects.toThrow("__exit__");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("filters out sprite-console connections", async () => {
const spriteConsole: SpawnRecord = {
...baseRecord,
connection: {
...baseRecord.connection!,
ip: "sprite-console",
},
};
await expect(
cmdExport(undefined, {
records: [
spriteConsole,
],
}),
).rejects.toThrow("__exit__");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("errors with a target hint when the named spawn doesn't exist", async () => {
await expect(
cmdExport("nonexistent", {
records: [
baseRecord,
],
}),
).rejects.toThrow("__exit__");
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

View file

@ -0,0 +1,478 @@
// commands/export.ts — `spawn export [name|id]`
//
// Captures a running claude spawn as a redistributable github repo. The
// output is the symmetric inverse of `--repo`: today `spawn claude hetzner
// --repo user/template` consumes a repo. After `spawn export`, the user
// gets a `spawn claude <cloud> --repo user/<slug>` line they can hand off
// or re-run.
//
// v1 scope: claude only.
// - When the user has multiple claude spawns, a picker lists them.
// - The repo name is decided by claude on the VM (`claude -p` with a
// name-suggestion prompt) — the human is never asked. The gh username
// comes from `gh api user`.
// - Before the commit, every staged file is scanned for known API-key
// shapes (Anthropic, OpenRouter, OpenAI, GitHub, AWS, PEM, Hetzner,
// DigitalOcean). Hits abort the export.
import type { SpawnRecord } from "../history.js";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as p from "@clack/prompts";
import { getErrorMessage } from "@openrouter/spawn-shared";
import pc from "picocolors";
import * as v from "valibot";
import { filterHistory } from "../history.js";
import { parseJsonWith } from "../shared/parse.js";
import { asyncTryCatch } from "../shared/result.js";
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js";
import { makeSshRunner } from "../shared/ssh-runner.js";
import { buildRecordLabel, buildRecordSubtitle } from "./list.js";
import { handleCancel } from "./shared.js";
const CLAUDE_AGENT = "claude";
const REMOTE_RESULT_PATH = "/tmp/spawn-export-result.json";
/** Default --steps list when the original launch_cmd doesn't carry one.
* Picked to be the standard "0 prompts" claude provisioning set:
* github auth + auto-update + security-scan are all defaultOn-equivalent
* for normal spawns. */
const DEFAULT_STEPS = "github,auto-update,security-scan";
/** Parse `--steps <value>` (or `--steps=<value>`) out of a saved launch_cmd.
* Returns the comma-separated string verbatim, or null if the flag is
* absent. The respawn consumer re-validates the names. */
export function parseStepsFromLaunchCmd(cmd: string | undefined): string | null {
if (!cmd) {
return null;
}
// Anchor to start or whitespace so `--no-steps` etc. never match.
const eq = cmd.match(/(?:^|\s)--steps=([^\s]+)/);
if (eq) {
return eq[1];
}
const space = cmd.match(/(?:^|\s)--steps\s+([^\s]+)/);
if (space) {
return space[1];
}
return null;
}
/** Resolve the --steps value to bake into the spawn link. */
export function resolveSteps(record: SpawnRecord): string {
return parseStepsFromLaunchCmd(record.connection?.launch_cmd) ?? DEFAULT_STEPS;
}
/** Result the on-VM script writes to REMOTE_RESULT_PATH. */
const ResultSchema = v.union([
v.object({
ok: v.literal(true),
slug: v.string(),
url: v.string(),
}),
v.object({
ok: v.literal(false),
error: v.string(),
}),
]);
/** Filter to records the export can actually drive: claude, with a live SSH
* connection, not deleted, not sprite-console. */
function exportableClaudeRecords(records: SpawnRecord[]): SpawnRecord[] {
return records.filter((r) => {
if (r.agent !== CLAUDE_AGENT) {
return false;
}
const c = r.connection;
if (!c) {
return false;
}
if (c.deleted) {
return false;
}
if (c.ip === "sprite-console") {
return false;
}
return true;
});
}
/** Find a claude spawn by name or id. */
function matchTarget(records: SpawnRecord[], target: string): SpawnRecord | null {
return records.find((r) => r.id === target || r.name === target || r.connection?.server_name === target) ?? null;
}
/** Build the spawn.md content from a record. Re-spawning consumes this. */
export function buildSpawnMd(record: SpawnRecord): string {
const lines: string[] = [
"---",
];
if (record.name) {
lines.push(`name: ${JSON.stringify(record.name)}`);
}
lines.push(`description: ${JSON.stringify(`Exported from spawn ${record.id}`)}`);
lines.push("---");
lines.push("");
lines.push(`# ${record.name ?? "spawn export"}`);
lines.push("");
lines.push("This template was generated by `spawn export`. Re-spawn it with the");
lines.push("command in the README.");
lines.push("");
return lines.join("\n");
}
/** Aggressive default .gitignore. The pre-commit secret scan is the real
* backstop; this just keeps obviously-private paths out of the staged tree
* before the scan runs. */
export function buildGitignore(): string {
return [
"# spawn export defaults",
"node_modules/",
"dist/",
"build/",
".next/",
"target/",
".cache/",
"coverage/",
"*.log",
".env",
".env.*",
".spawnrc",
".bash_history",
".zsh_history",
".aws/",
".config/spawn/",
".config/gcloud/",
".gnupg/",
"*.key",
"*.pem",
"*.token",
"*.credentials",
"id_rsa*",
"id_ed25519*",
".DS_Store",
"",
].join("\n");
}
/** README template the bash script substitutes __SLUG__, __CLOUD__,
* __NAME__, __STEPS__ at runtime once claude has picked a name. */
export function buildReadmeTemplate(): string {
return [
"# __NAME__",
"",
"Exported from a [spawn](https://github.com/OpenRouterTeam/spawn) session on `__CLOUD__`.",
"",
"## Quickstart",
"",
"```bash",
"spawn claude __CLOUD__ --repo __SLUG__ --steps __STEPS__",
"```",
"",
"Re-spawning is non-interactive — the `--steps` list bakes in the same",
"setup decisions the original spawn made, so you won't be prompted.",
"",
"## First-run checklist",
"",
"- [ ] `gh auth login` — re-auth GitHub on the new VM",
"- [ ] Re-OAuth any MCP servers used in the original session (Spotify, Linear, etc.)",
"- [ ] Run any project-specific install commands (e.g. `npm install`) in `project/`",
"",
"## What's in this repo",
"",
"- `project/` — the working tree at `~/project` from the source VM",
"- `claude/` — sanitized agent config: skills, commands, hooks, CLAUDE.md, settings",
"- `spawn.md` — machine-readable re-spawn metadata",
"",
].join("\n");
}
/** Generate the bash script that runs on the VM. */
export function buildExportScript(opts: {
spawnMd: string;
readmeTemplate: string;
gitignore: string;
cloud: string;
steps: string;
visibility: "private" | "public";
resultPath: string;
}): string {
const visibilityFlag = opts.visibility === "public" ? "--public" : "--private";
return [
"#!/bin/bash",
"set -eo pipefail",
"",
`RESULT_PATH=${shSingleQuote(opts.resultPath)}`,
`CLOUD=${shSingleQuote(opts.cloud)}`,
`STEPS=${shSingleQuote(opts.steps)}`,
`VISIBILITY_FLAG=${visibilityFlag}`,
"",
'EXPORT_DIR="$(mktemp -d)"',
'trap "rm -rf \\"$EXPORT_DIR\\"" EXIT',
"",
"# 1. Heredoc the static files (spawn.md, .gitignore, README template)",
`cat > "$EXPORT_DIR/spawn.md" <<'SPAWN_MD_EOF'`,
opts.spawnMd,
"SPAWN_MD_EOF",
"",
`cat > "$EXPORT_DIR/.gitignore" <<'GITIGNORE_EOF'`,
opts.gitignore,
"GITIGNORE_EOF",
"",
`cat > "$EXPORT_DIR/README.md" <<'README_EOF'`,
opts.readmeTemplate,
"README_EOF",
"",
"# 2. Copy working tree (rsync excludes the obvious junk).",
'if [ -d "$HOME/project" ]; then',
' mkdir -p "$EXPORT_DIR/project"',
' rsync -a --exclude=node_modules --exclude=.git --exclude=dist --exclude=.next --exclude=target --exclude=.env --exclude=".env.*" "$HOME/project/" "$EXPORT_DIR/project/"',
"fi",
"",
"# 3. Copy sanitized claude system dir.",
'mkdir -p "$EXPORT_DIR/claude"',
"for d in skills commands hooks; do",
' if [ -d "$HOME/.claude/$d" ]; then',
' rsync -a --exclude=.git "$HOME/.claude/$d/" "$EXPORT_DIR/claude/$d/"',
" fi",
"done",
"for f in CLAUDE.md AGENTS.md settings.json; do",
' if [ -f "$HOME/.claude/$f" ]; then',
' cp "$HOME/.claude/$f" "$EXPORT_DIR/claude/$f"',
" fi",
"done",
"",
"# 4. Strip token-shaped keys from settings.json.",
'if [ -f "$EXPORT_DIR/claude/settings.json" ] && command -v bun >/dev/null; then',
' _SETTINGS_PATH="$EXPORT_DIR/claude/settings.json" bun -e "',
" const path = process.env._SETTINGS_PATH;",
" const raw = await Bun.file(path).text();",
" let parsed; try { parsed = JSON.parse(raw); } catch { process.exit(0); }",
" if (parsed && typeof parsed === 'object') {",
" const denyRe = /(token|secret|password|api[_-]?key|auth)/i;",
" const scrub = (obj) => {",
" if (!obj || typeof obj !== 'object') return;",
" for (const k of Object.keys(obj)) {",
" if (denyRe.test(k)) { delete obj[k]; continue; }",
" if (typeof obj[k] === 'object') scrub(obj[k]);",
" }",
" };",
" scrub(parsed);",
" await Bun.write(path, JSON.stringify(parsed, null, 2));",
" }",
' " || true',
"fi",
"",
"# 5. Ask claude to suggest a kebab-case repo name.",
'PROJECT_NAME=""',
"if command -v claude >/dev/null; then",
' CLAUDE_PROMPT="You are choosing a github repo name for an export of this VM. Look at ~/project (the working tree) and any README/package.json to infer the project. Output ONLY a short kebab-case repo name, max 40 chars, lowercase, [a-z0-9-] only. No explanation, no quotes."',
' SUGGESTED="$(claude -p "$CLAUDE_PROMPT" 2>/dev/null | head -n 1 || true)"',
' PROJECT_NAME="$(printf "%s" "$SUGGESTED" | tr "A-Z" "a-z" | sed -E "s/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//" | cut -c1-40)"',
"fi",
'if [ -z "$PROJECT_NAME" ]; then',
' if [ -d "$HOME/project" ]; then',
' PROJECT_NAME="$(basename "$HOME/project" | tr "A-Z" "a-z" | sed -E "s/[^a-z0-9-]+/-/g" | cut -c1-40)"',
" fi",
"fi",
'if [ -z "$PROJECT_NAME" ]; then',
' PROJECT_NAME="spawn-export-$(date +%s)"',
"fi",
"",
"# 6. Look up the gh user. Required.",
'GH_USER="$(gh api user --jq .login 2>/dev/null || true)"',
'if [ -z "$GH_USER" ]; then',
' printf \'%s\\n\' \'{"ok":false,"error":"gh is not authenticated on the VM. Run `gh auth login` and retry."}\' > "$RESULT_PATH"',
" exit 1",
"fi",
'SLUG="$GH_USER/$PROJECT_NAME"',
"",
"# 7. Substitute placeholders into README.",
'sed -i "s|__NAME__|$PROJECT_NAME|g; s|__CLOUD__|$CLOUD|g; s|__SLUG__|$SLUG|g; s|__STEPS__|$STEPS|g" "$EXPORT_DIR/README.md"',
"",
"# 8. Stage everything.",
'cd "$EXPORT_DIR"',
"git init -q -b main",
"git add -A",
"",
"# 9. SECRETS SCAN — abort if any staged file matches known API-key shapes.",
"SECRET_REGEX='(sk-or-v1-[a-f0-9]{20,})|(sk-ant-api[0-9-]+_[A-Za-z0-9_-]{20,})|(sk-proj-[A-Za-z0-9_-]{20,})|(gh[ops]_[A-Za-z0-9]{36})|(AKIA[0-9A-Z]{16})|(hcloud_[a-zA-Z0-9_-]{20,})|(dop_v1_[a-f0-9]{32,})|(-----BEGIN ([A-Z]+ )?PRIVATE KEY-----)'",
'SECRET_HITS="$(git ls-files -z | xargs -0 grep -lEa "$SECRET_REGEX" 2>/dev/null || true)"',
'if [ -n "$SECRET_HITS" ]; then',
' printf \'%s\\n\' \'{"ok":false,"error":"Possible secrets detected in staged files; aborting export. SSH in and inspect the files listed below."}\' > "$RESULT_PATH"',
' echo "✗ Possible secrets detected in:" >&2',
' printf "%s\\n" "$SECRET_HITS" >&2',
" exit 1",
"fi",
"",
"# 10. Commit and push.",
'git -c user.email=spawn-export@openrouter.ai -c user.name="spawn export" commit -q -m "spawn export"',
"",
'gh repo create "$SLUG" "$VISIBILITY_FLAG" --source=. --push --description="Exported with spawn"',
"",
"# 11. Emit the success result.",
'printf \'{"ok":true,"slug":"%s","url":"https://github.com/%s"}\\n\' "$SLUG" "$SLUG" > "$RESULT_PATH"',
"",
].join("\n");
}
/** Single-quote a string for safe inclusion in a bash script. */
function shSingleQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}
/** Pick one record from a list of claude spawns. */
async function pickOne(records: SpawnRecord[]): Promise<SpawnRecord | null> {
const options = records.map((r) => ({
value: r.id ?? r.timestamp,
label: buildRecordLabel(r),
hint: buildRecordSubtitle(r, null),
}));
const choice = await p.select({
message: "Which claude spawn do you want to export?",
options,
});
if (p.isCancel(choice)) {
return null;
}
return records.find((r) => (r.id ?? r.timestamp) === choice) ?? null;
}
/** Options for cmdExport — injectable for testing. */
export interface ExportOptions {
/** Override the runner construction (test injection). */
makeRunner?: (
ip: string,
user: string,
keyOpts: string[],
) => {
runServer: (cmd: string, timeoutSecs?: number) => Promise<void>;
downloadFile: (remotePath: string, localPath: string) => Promise<void>;
uploadFile: (localPath: string, remotePath: string) => Promise<void>;
};
/** Override visibility. If omitted, the user is prompted interactively
* with a "make public?" confirm that defaults to no (i.e. private). */
visibility?: "private" | "public";
/** Inject the candidate records directly (test seam to skip filterHistory). */
records?: SpawnRecord[];
}
/** Top-level command: `spawn export [target]`. */
export async function cmdExport(target: string | undefined, options?: ExportOptions): Promise<void> {
const all = options?.records ?? filterHistory();
const exportable = exportableClaudeRecords(all);
if (exportable.length === 0) {
p.log.info("No claude spawns available to export.");
p.log.info(`Run ${pc.cyan("spawn claude <cloud>")} first, then export the result.`);
process.exit(1);
}
let picked: SpawnRecord | null = null;
if (target) {
picked = matchTarget(exportable, target);
if (!picked) {
p.log.error(`No claude spawn matches ${pc.bold(target)}.`);
p.log.info(`Run ${pc.cyan("spawn list -a claude")} to see available targets.`);
process.exit(1);
}
} else if (exportable.length === 1) {
picked = exportable[0] ?? null;
} else {
picked = await pickOne(exportable);
if (!picked) {
handleCancel(); // never returns
}
}
if (!picked) {
// Defensive: the branches above either assign or exit, so this should
// be unreachable. The explicit check keeps TypeScript narrowing happy
// without an `!` non-null assertion.
handleCancel();
}
const r: SpawnRecord = picked;
const conn = r.connection;
if (!conn) {
// exportableClaudeRecords guarantees connection is present — a missing
// connection here means state was mutated between filter and use.
p.log.error("Internal error: selected spawn has no connection info.");
process.exit(1);
}
p.log.step(`Exporting ${pc.bold(buildRecordLabel(r))} ${pc.dim(`(${buildRecordSubtitle(r, null)})`)}`);
// Visibility: private by default. If the caller didn't force one (tests do),
// ask the user whether to make the exported repo public. A private repo is
// the safe default — the secret scan is a backstop, not a guarantee.
let visibility: "private" | "public";
if (options?.visibility) {
visibility = options.visibility;
} else {
const makePublic = await p.confirm({
message: "Make the exported repo public on GitHub?",
initialValue: false,
});
if (p.isCancel(makePublic)) {
handleCancel();
}
visibility = makePublic === true ? "public" : "private";
}
const steps = resolveSteps(r);
const script = buildExportScript({
spawnMd: buildSpawnMd(r),
readmeTemplate: buildReadmeTemplate(),
gitignore: buildGitignore(),
cloud: r.cloud,
steps,
visibility,
resultPath: REMOTE_RESULT_PATH,
});
// SSH runner
const keyOpts = options?.makeRunner ? [] : getSshKeyOpts(await ensureSshKeys());
const runner = options?.makeRunner
? options.makeRunner(conn.ip, conn.user, keyOpts)
: makeSshRunner(conn.ip, conn.user, keyOpts);
// Run the export script. 10-min timeout — large repos take time to push.
p.log.step("Running export on the VM (claude is naming the repo)...");
const runResult = await asyncTryCatch(() => runner.runServer(script, 600));
if (!runResult.ok) {
p.log.error(`Export failed: ${getErrorMessage(runResult.error)}`);
p.log.info("Check that `gh` is authenticated on the VM (`gh auth status`).");
process.exit(1);
}
// Download result file
const localTmp = mkdtempSync(join(tmpdir(), "spawn-export-"));
const localResult = join(localTmp, "result.json");
const dlResult = await asyncTryCatch(() => runner.downloadFile(REMOTE_RESULT_PATH, localResult));
if (!dlResult.ok) {
rmSync(localTmp, {
recursive: true,
force: true,
});
p.log.error(`Could not read export result: ${getErrorMessage(dlResult.error)}`);
process.exit(1);
}
const text = readFileSync(localResult, "utf8");
rmSync(localTmp, {
recursive: true,
force: true,
});
const parsed = parseJsonWith(text, ResultSchema);
if (!parsed) {
p.log.error("Export ran but produced no parseable result.");
process.exit(1);
}
if (!parsed.ok) {
p.log.error(parsed.error);
process.exit(1);
}
console.log();
p.log.success(`Exported to ${pc.cyan(parsed.url)}`);
console.log();
console.log(pc.dim("Re-spawn with:"));
console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug} --steps ${steps}`)}`);
console.log();
}

View file

@ -44,6 +44,8 @@ function getHelpUsageSection(): string {
spawn link <ip> Register an existing VM by IP (alias: reconnect)
spawn link <ip> --agent <agent> Specify the agent running on the VM
spawn link <ip> --cloud <cloud> Specify the cloud provider
spawn export Export a claude spawn to a github repo (re-spawn via --repo)
spawn export <name> Export a specific spawn by name or ID
spawn last Instantly rerun the most recent spawn (alias: rerun)
spawn matrix Full availability matrix (alias: m)
spawn agents List all agents with descriptions

View file

@ -2,6 +2,16 @@
// delete.ts — cmdDelete, cascadeDelete
export { cascadeDelete, cmdDelete } from "./delete.js";
// export.ts — cmdExport (capture a claude spawn into a redistributable github repo)
export {
buildExportScript,
buildGitignore,
buildReadmeTemplate,
buildSpawnMd,
cmdExport,
parseStepsFromLaunchCmd,
resolveSteps,
} from "./export.js";
// feedback.ts — cmdFeedback
export { cmdFeedback } from "./feedback.js";
// fix.ts — cmdFix, fixSpawn

View file

@ -13,6 +13,7 @@ import {
cmdCloudInfo,
cmdClouds,
cmdDelete,
cmdExport,
cmdFeedback,
cmdFix,
cmdHelp,
@ -819,6 +820,15 @@ async function dispatchCommand(
await cmdLink(filteredArgs);
return;
}
if (cmd === "export") {
if (hasTrailingHelpFlag(filteredArgs)) {
cmdHelp();
return;
}
const targetArg = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined;
await cmdExport(targetArg);
return;
}
if (VERB_ALIASES.has(cmd)) {
await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug, headless, outputFormat);
return;