From 78597df27afe1343baf1d0084765f4f223e2df58 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Fri, 1 May 2026 00:30:45 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(cli):=20spawn=20export=20=E2=80=94=20c?= =?UTF-8?q?apture=20a=20claude=20session=20into=20a=20github=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/export.test.ts | 202 +++++++++++ packages/cli/src/commands/export.ts | 407 ++++++++++++++++++++++ packages/cli/src/commands/help.ts | 2 + packages/cli/src/commands/index.ts | 8 + packages/cli/src/index.ts | 10 + 6 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/export.test.ts create mode 100644 packages/cli/src/commands/export.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 8b2c002aa..95af7202e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.27", + "version": "1.1.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts new file mode 100644 index 000000000..4101a5a9b --- /dev/null +++ b/packages/cli/src/__tests__/export.test.ts @@ -0,0 +1,202 @@ +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, buildReadme, buildSpawnMd, cmdExport } 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; +let stdoutSpy: ReturnType; +let exitSpy: ReturnType; + +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("buildReadme", () => { + it("contains the canonical re-spawn command for claude", () => { + const readme = buildReadme(baseRecord, "alice/demo"); + expect(readme).toContain("spawn claude hetzner --repo alice/demo"); + }); + + it("renders a github-friendly checklist", () => { + const readme = buildReadme(baseRecord, "alice/demo"); + expect(readme).toContain("- [ ] `gh auth login`"); + expect(readme).toContain("- [ ] Re-OAuth"); + }); +}); + +describe("buildGitignore", () => { + it("excludes node_modules and env files", () => { + const gi = buildGitignore(); + expect(gi).toContain("node_modules/"); + expect(gi).toContain(".env"); + expect(gi).toContain(".env.*"); + }); +}); + +describe("buildExportScript", () => { + const opts = { + spawnMd: "---\nname: x\n---\n", + readme: "# x\n", + gitignore: "node_modules/\n", + slug: "alice/demo", + 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("calls gh repo create with the right slug and visibility", () => { + const s = buildExportScript(opts); + expect(s).toContain("gh repo create 'alice/demo' --private"); + expect(s).toContain("--source=. --push"); + }); + + it("flips to --public when visibility is public", () => { + const s = buildExportScript({ + ...opts, + visibility: "public", + }); + expect(s).toContain("--public"); + expect(s).not.toContain("--private"); + }); + + it("writes the result JSON to the supplied path", () => { + const s = buildExportScript({ + ...opts, + resultPath: "/tmp/custom.json", + }); + expect(s).toContain("'/tmp/custom.json'"); + expect(s).toContain('"slug":"alice/demo"'); + expect(s).toContain('"url":"https://github.com/alice/demo"'); + }); +}); + +// ── cmdExport orchestration ───────────────────────────────────────────────── + +describe("cmdExport", () => { + it("errors out when the target spawn isn't claude", async () => { + const codexRecord: SpawnRecord = { + ...baseRecord, + agent: "codex", + }; + await expect( + cmdExport(undefined, { + records: [ + codexRecord, + ], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors out when no claude spawns exist", async () => { + await expect( + cmdExport(undefined, { + records: [], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rejects spawns without connection info", async () => { + const noConn: SpawnRecord = { + ...baseRecord, + connection: undefined, + }; + await expect( + cmdExport(undefined, { + records: [ + noConn, + ], + repo: { + slug: "a/b", + visibility: "private", + }, + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rejects deleted spawns", async () => { + const deleted: SpawnRecord = { + ...baseRecord, + connection: { + ...baseRecord.connection!, + deleted: true, + }, + }; + await expect( + cmdExport(undefined, { + records: [ + deleted, + ], + repo: { + slug: "a/b", + visibility: "private", + }, + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts new file mode 100644 index 000000000..a3f2c900b --- /dev/null +++ b/packages/cli/src/commands/export.ts @@ -0,0 +1,407 @@ +// 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 --repo user/` line they can hand off +// or re-run. +// +// v1 scope: claude only. Mechanical capture (no agent introspection): +// - ~/project/ (working tree, with aggressive .gitignore) +// - ~/.claude/{skills,commands,hooks,CLAUDE.md,AGENTS.md,settings.json} +// (settings.json is sanitized to strip likely tokens) +// - generated spawn.md (frontmatter only — re-spawn metadata) +// - generated README.md (re-spawn command + re-auth checklist for github) +// +// Followups (not in v1): +// - claude reads its own session history to enumerate MCPs / OAuth +// providers and writes them into spawn.md's setup steps +// - local cloud target (currently routed through SshRunner like every +// other cloud — works for SSH-backed clouds; local cloud uses a +// trivial direct branch added below) + +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 { shellQuote } from "../shared/ui.js"; +import { buildRecordLabel, buildRecordSubtitle } from "./list.js"; +import { handleCancel, isInteractiveTTY } from "./shared.js"; + +const CLAUDE_AGENT = "claude"; +const REMOTE_RESULT_PATH = "/tmp/spawn-export-result.json"; + +const ResultSchema = v.object({ + slug: v.string(), + url: v.string(), +}); + +const SLUG_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; +const REPO_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/; + +/** Find a claude spawn by name, id, or fall back to most-recent. */ +function resolveTarget(target: string | undefined, records?: SpawnRecord[]): SpawnRecord | null { + const all = records ?? filterHistory(); + const claudeRecords = all.filter((r) => r.agent === CLAUDE_AGENT); + if (claudeRecords.length === 0) { + return null; + } + if (!target) { + return claudeRecords[0]; + } + return ( + claudeRecords.find((r) => r.id === target || r.name === target || r.connection?.server_name === target) ?? null + ); +} + +/** Sanitize a session name into a valid GitHub repo name. */ +function sanitizeRepoName(input: string): string { + const cleaned = input + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^[-.]+|[-.]+$/g, "") + .slice(0, 80); + return cleaned || "spawn-export"; +} + +/** 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"); +} + +/** Build the README. Renders as a re-auth checklist on github. */ +export function buildReadme(record: SpawnRecord, slug: string): string { + const cloud = record.cloud; + const respawn = `spawn ${CLAUDE_AGENT} ${cloud} --repo ${slug}`; + return [ + `# ${record.name ?? slug}`, + "", + `Exported from a [spawn](https://github.com/OpenRouterTeam/spawn) session on \`${cloud}\`.`, + "", + "## Quickstart", + "", + "```bash", + respawn, + "```", + "", + "## 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"); +} + +/** Aggressive default .gitignore — better safe than committing secrets. */ +export function buildGitignore(): string { + return [ + "# spawn export defaults", + "node_modules/", + "dist/", + "build/", + ".next/", + "target/", + ".cache/", + "coverage/", + "*.log", + ".env", + ".env.*", + "*.key", + "*.pem", + "id_rsa*", + "id_ed25519*", + ".DS_Store", + "", + ].join("\n"); +} + +/** Generate the bash script that runs on the VM. */ +export function buildExportScript(opts: { + spawnMd: string; + readme: string; + gitignore: string; + slug: string; + visibility: "private" | "public"; + resultPath: string; +}): string { + const visibilityFlag = opts.visibility === "public" ? "--public" : "--private"; + return [ + "#!/bin/bash", + "set -eo pipefail", + "", + 'EXPORT_DIR="$(mktemp -d)"', + 'trap "rm -rf \\"$EXPORT_DIR\\"" EXIT', + "", + `cat > "$EXPORT_DIR/spawn.md" <<'SPAWN_MD_EOF'`, + opts.spawnMd, + "SPAWN_MD_EOF", + "", + `cat > "$EXPORT_DIR/README.md" <<'README_EOF'`, + opts.readme, + "README_EOF", + "", + `cat > "$EXPORT_DIR/.gitignore" <<'GITIGNORE_EOF'`, + opts.gitignore, + "GITIGNORE_EOF", + "", + "# Copy working tree if present", + '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", + "", + "# 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 "$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", + "", + "# Strip likely-secret keys from settings.json (best effort).", + "# Removes top-level fields whose name matches token/key/secret/password.", + '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;", + " for (const k of Object.keys(parsed)) { if (denyRe.test(k)) delete parsed[k]; }", + " if (parsed.env && typeof parsed.env === 'object') {", + " for (const k of Object.keys(parsed.env)) { if (denyRe.test(k)) delete parsed.env[k]; }", + " }", + " await Bun.write(path, JSON.stringify(parsed, null, 2));", + " }", + ' " || true', + "fi", + "", + 'cd "$EXPORT_DIR"', + "git init -q -b main", + "git add -A", + 'git -c user.email=spawn-export@openrouter.ai -c user.name="spawn export" commit -q -m "spawn export"', + "", + `gh repo create ${shellQuote(opts.slug)} ${visibilityFlag} --source=. --push --description=${shellQuote("Exported with spawn")}`, + "", + `printf '%s\\n' '{"slug":${JSON.stringify(opts.slug).replace(/'/g, "'\\''")},"url":"https://github.com/${opts.slug}"}' > ${shellQuote(opts.resultPath)}`, + "", + ].join("\n"); +} + +/** Prompt for slug + visibility. */ +async function promptForRepo(record: SpawnRecord): Promise<{ + slug: string; + visibility: "private" | "public"; +} | null> { + const defaultName = sanitizeRepoName(record.name ?? record.connection?.server_name ?? "spawn-export"); + const slug = await p.text({ + message: "GitHub repo to export to (user/repo)", + placeholder: `your-username/${defaultName}`, + validate: (value) => { + if (!value) { + return "Required. Format: user/repo"; + } + if (!SLUG_PATTERN.test(value)) { + return "Must be in user/repo format with only [a-zA-Z0-9_.-]"; + } + const repoPart = value.split("/")[1]; + if (!REPO_NAME_PATTERN.test(repoPart)) { + return "Repo name must contain only [a-zA-Z0-9_.-]"; + } + return undefined; + }, + }); + if (p.isCancel(slug)) { + return null; + } + + const visibility = await p.select({ + message: "Visibility", + options: [ + { + value: "private", + label: "Private", + hint: "default", + }, + { + value: "public", + label: "Public", + }, + ], + initialValue: "private", + }); + if (p.isCancel(visibility)) { + return null; + } + + return { + slug, + visibility: visibility === "public" ? "public" : "private", + }; +} + +/** 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; + downloadFile: (remotePath: string, localPath: string) => Promise; + uploadFile: (localPath: string, remotePath: string) => Promise; + }; + /** Skip interactive prompts; supply repo info directly. */ + repo?: { + slug: string; + 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 { + const record = resolveTarget(target, options?.records); + if (!record) { + if (target) { + p.log.error(`No claude spawn matches ${pc.bold(target)}.`); + p.log.info(`Run ${pc.cyan("spawn list -a claude")} to see available targets.`); + } else { + p.log.info("No claude spawns recorded yet."); + p.log.info(`Run ${pc.cyan("spawn claude ")} first, then export the result.`); + } + process.exit(1); + } + + if (record.agent !== CLAUDE_AGENT) { + p.log.error(`spawn export currently supports claude only (got ${pc.bold(record.agent)}).`); + p.log.info("Other agents will be added once their config introspection lands."); + process.exit(1); + } + + const conn = record.connection; + if (!conn) { + p.log.error("Cannot export: spawn has no connection information."); + process.exit(1); + } + if (conn.deleted) { + p.log.error("Cannot export: server has been deleted."); + process.exit(1); + } + if (conn.ip === "sprite-console") { + p.log.error("Cannot export: Sprite-console connections aren't SSH-accessible."); + p.log.info("SSH directly into the VM and run `gh repo create` manually."); + process.exit(1); + } + + p.log.step(`Exporting ${pc.bold(buildRecordLabel(record))} ${pc.dim(`(${buildRecordSubtitle(record, null)})`)}`); + + // Resolve repo target + let repo = options?.repo; + if (!repo) { + if (!isInteractiveTTY()) { + p.log.error("spawn export requires --repo in non-interactive mode (not yet supported)."); + process.exit(1); + } + const prompted = await promptForRepo(record); + if (!prompted) { + handleCancel(); + } + repo = prompted!; + } + + // Build content + const spawnMd = buildSpawnMd(record); + const readme = buildReadme(record, repo.slug); + const gitignore = buildGitignore(); + const script = buildExportScript({ + spawnMd, + readme, + gitignore, + slug: repo.slug, + visibility: repo.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..."); + 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); + } + 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} ${record.cloud} --repo ${parsed.slug}`)}`); + console.log(); +} diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 067101a8e..39ace40f1 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -44,6 +44,8 @@ function getHelpUsageSection(): string { spawn link Register an existing VM by IP (alias: reconnect) spawn link --agent Specify the agent running on the VM spawn link --cloud Specify the cloud provider + spawn export Export a claude spawn to a github repo (re-spawn via --repo) + spawn export 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 diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 309a56362..129f56f5c 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -2,6 +2,14 @@ // 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, + buildReadme, + buildSpawnMd, + cmdExport, +} from "./export.js"; // feedback.ts — cmdFeedback export { cmdFeedback } from "./feedback.js"; // fix.ts — cmdFix, fixSpawn diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0892969e7..2d7bdded6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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; From 89779dbac6a402c7c4d63c66c0a97d338c62716a Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Fri, 1 May 2026 12:40:01 -0700 Subject: [PATCH 2/6] fix(cli): use patch bump (1.0.28) to match team versioning cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 95af7202e..a9b9e0fbc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.1.0", + "version": "1.0.28", "type": "module", "bin": { "spawn": "cli.js" From a044a6879c817935b107d6026508d8ba3605cec9 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Fri, 1 May 2026 23:12:29 -0700 Subject: [PATCH 3/6] feat(export): list picker, claude picks the slug, pre-commit secrets scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/export.test.ts | 146 +++++--- packages/cli/src/commands/export.ts | 391 +++++++++++----------- packages/cli/src/commands/index.ts | 2 +- 4 files changed, 310 insertions(+), 231 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index a9b9e0fbc..9070c3738 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.28", + "version": "1.0.29", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts index 4101a5a9b..0d08d19a5 100644 --- a/packages/cli/src/__tests__/export.test.ts +++ b/packages/cli/src/__tests__/export.test.ts @@ -5,7 +5,7 @@ mockClackPrompts(); import type { SpawnRecord } from "../history"; -import { buildExportScript, buildGitignore, buildReadme, buildSpawnMd, cmdExport } from "../commands/export"; +import { buildExportScript, buildGitignore, buildReadmeTemplate, buildSpawnMd, cmdExport } from "../commands/export"; import { parseSpawnMd } from "../shared/spawn-md"; const baseRecord: SpawnRecord = { @@ -63,34 +63,41 @@ describe("buildSpawnMd", () => { }); }); -describe("buildReadme", () => { - it("contains the canonical re-spawn command for claude", () => { - const readme = buildReadme(baseRecord, "alice/demo"); - expect(readme).toContain("spawn claude hetzner --repo alice/demo"); +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("spawn claude __CLOUD__ --repo __SLUG__"); }); it("renders a github-friendly checklist", () => { - const readme = buildReadme(baseRecord, "alice/demo"); - expect(readme).toContain("- [ ] `gh auth login`"); - expect(readme).toContain("- [ ] Re-OAuth"); + const tpl = buildReadmeTemplate(); + expect(tpl).toContain("- [ ] `gh auth login`"); + expect(tpl).toContain("- [ ] Re-OAuth"); }); }); describe("buildGitignore", () => { - it("excludes node_modules and env files", () => { + 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("buildExportScript", () => { const opts = { spawnMd: "---\nname: x\n---\n", - readme: "# x\n", + readmeTemplate: "# __NAME__\n", gitignore: "node_modules/\n", - slug: "alice/demo", + cloud: "hetzner", visibility: "private" as const, resultPath: "/tmp/spawn-export-result.json", }; @@ -106,10 +113,42 @@ describe("buildExportScript", () => { expect(s).toContain('"$HOME/.claude/$d/"'); }); - it("calls gh repo create with the right slug and visibility", () => { + 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 repo create 'alice/demo' --private"); - expect(s).toContain("--source=. --push"); + 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", () => { @@ -117,8 +156,8 @@ describe("buildExportScript", () => { ...opts, visibility: "public", }); - expect(s).toContain("--public"); - expect(s).not.toContain("--private"); + expect(s).toContain("VISIBILITY_FLAG=--public"); + expect(s).not.toContain("VISIBILITY_FLAG=--private"); }); it("writes the result JSON to the supplied path", () => { @@ -126,16 +165,35 @@ describe("buildExportScript", () => { ...opts, resultPath: "/tmp/custom.json", }); - expect(s).toContain("'/tmp/custom.json'"); - expect(s).toContain('"slug":"alice/demo"'); - expect(s).toContain('"url":"https://github.com/alice/demo"'); + 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)"); }); }); // ── cmdExport orchestration ───────────────────────────────────────────────── describe("cmdExport", () => { - it("errors out when the target spawn isn't claude", async () => { + 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", @@ -150,51 +208,63 @@ describe("cmdExport", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); - it("errors out when no claude spawns exist", async () => { + it("filters out spawns without connection info", async () => { + const noConn: SpawnRecord = { + ...baseRecord, + connection: undefined, + }; await expect( cmdExport(undefined, { - records: [], + records: [ + noConn, + ], }), ).rejects.toThrow("__exit__"); expect(exitSpy).toHaveBeenCalledWith(1); }); - it("rejects spawns without connection info", async () => { - const noConn: SpawnRecord = { + it("filters out deleted spawns", async () => { + const deleted: SpawnRecord = { ...baseRecord, - connection: undefined, + connection: { + ...baseRecord.connection!, + deleted: true, + }, }; await expect( cmdExport(undefined, { records: [ - noConn, + deleted, ], - repo: { - slug: "a/b", - visibility: "private", - }, }), ).rejects.toThrow("__exit__"); expect(exitSpy).toHaveBeenCalledWith(1); }); - it("rejects deleted spawns", async () => { - const deleted: SpawnRecord = { + it("filters out sprite-console connections", async () => { + const spriteConsole: SpawnRecord = { ...baseRecord, connection: { ...baseRecord.connection!, - deleted: true, + ip: "sprite-console", }, }; await expect( cmdExport(undefined, { records: [ - deleted, + 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, ], - repo: { - slug: "a/b", - visibility: "private", - }, }), ).rejects.toThrow("__exit__"); expect(exitSpy).toHaveBeenCalledWith(1); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index a3f2c900b..4a1420d2c 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -6,19 +6,14 @@ // gets a `spawn claude --repo user/` line they can hand off // or re-run. // -// v1 scope: claude only. Mechanical capture (no agent introspection): -// - ~/project/ (working tree, with aggressive .gitignore) -// - ~/.claude/{skills,commands,hooks,CLAUDE.md,AGENTS.md,settings.json} -// (settings.json is sanitized to strip likely tokens) -// - generated spawn.md (frontmatter only — re-spawn metadata) -// - generated README.md (re-spawn command + re-auth checklist for github) -// -// Followups (not in v1): -// - claude reads its own session history to enumerate MCPs / OAuth -// providers and writes them into spawn.md's setup steps -// - local cloud target (currently routed through SshRunner like every -// other cloud — works for SSH-backed clouds; local cloud uses a -// trivial direct branch added below) +// 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"; @@ -34,44 +29,49 @@ 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 { shellQuote } from "../shared/ui.js"; import { buildRecordLabel, buildRecordSubtitle } from "./list.js"; -import { handleCancel, isInteractiveTTY } from "./shared.js"; +import { handleCancel } from "./shared.js"; const CLAUDE_AGENT = "claude"; const REMOTE_RESULT_PATH = "/tmp/spawn-export-result.json"; -const ResultSchema = v.object({ - slug: v.string(), - url: v.string(), -}); - -const SLUG_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; -const REPO_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/; +/** 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(), + }), +]); -/** Find a claude spawn by name, id, or fall back to most-recent. */ -function resolveTarget(target: string | undefined, records?: SpawnRecord[]): SpawnRecord | null { - const all = records ?? filterHistory(); - const claudeRecords = all.filter((r) => r.agent === CLAUDE_AGENT); - if (claudeRecords.length === 0) { - return null; - } - if (!target) { - return claudeRecords[0]; - } - return ( - claudeRecords.find((r) => r.id === target || r.name === target || r.connection?.server_name === target) ?? null - ); +/** 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; + }); } -/** Sanitize a session name into a valid GitHub repo name. */ -function sanitizeRepoName(input: string): string { - const cleaned = input - .toLowerCase() - .replace(/[^a-z0-9_.-]+/g, "-") - .replace(/^[-.]+|[-.]+$/g, "") - .slice(0, 80); - return cleaned || "spawn-export"; +/** 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. */ @@ -93,19 +93,52 @@ export function buildSpawnMd(record: SpawnRecord): string { return lines.join("\n"); } -/** Build the README. Renders as a re-auth checklist on github. */ -export function buildReadme(record: SpawnRecord, slug: string): string { - const cloud = record.cloud; - const respawn = `spawn ${CLAUDE_AGENT} ${cloud} --repo ${slug}`; +/** 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__ at runtime once claude has picked a name. */ +export function buildReadmeTemplate(): string { return [ - `# ${record.name ?? slug}`, + "# __NAME__", "", - `Exported from a [spawn](https://github.com/OpenRouterTeam/spawn) session on \`${cloud}\`.`, + "Exported from a [spawn](https://github.com/OpenRouterTeam/spawn) session on `__CLOUD__`.", "", "## Quickstart", "", "```bash", - respawn, + "spawn claude __CLOUD__ --repo __SLUG__", "```", "", "## First-run checklist", @@ -123,35 +156,12 @@ export function buildReadme(record: SpawnRecord, slug: string): string { ].join("\n"); } -/** Aggressive default .gitignore — better safe than committing secrets. */ -export function buildGitignore(): string { - return [ - "# spawn export defaults", - "node_modules/", - "dist/", - "build/", - ".next/", - "target/", - ".cache/", - "coverage/", - "*.log", - ".env", - ".env.*", - "*.key", - "*.pem", - "id_rsa*", - "id_ed25519*", - ".DS_Store", - "", - ].join("\n"); -} - /** Generate the bash script that runs on the VM. */ export function buildExportScript(opts: { spawnMd: string; - readme: string; + readmeTemplate: string; gitignore: string; - slug: string; + cloud: string; visibility: "private" | "public"; resultPath: string; }): string { @@ -160,28 +170,33 @@ export function buildExportScript(opts: { "#!/bin/bash", "set -eo pipefail", "", + `RESULT_PATH=${shSingleQuote(opts.resultPath)}`, + `CLOUD=${shSingleQuote(opts.cloud)}`, + `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/README.md" <<'README_EOF'`, - opts.readme, - "README_EOF", - "", `cat > "$EXPORT_DIR/.gitignore" <<'GITIGNORE_EOF'`, opts.gitignore, "GITIGNORE_EOF", "", - "# Copy working tree if present", + `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", "", - "# Copy sanitized claude system dir", + "# 3. Copy sanitized claude system dir.", 'mkdir -p "$EXPORT_DIR/claude"', "for d in skills commands hooks; do", ' if [ -d "$HOME/.claude/$d" ]; then', @@ -194,8 +209,7 @@ export function buildExportScript(opts: { " fi", "done", "", - "# Strip likely-secret keys from settings.json (best effort).", - "# Removes top-level fields whose name matches token/key/secret/password.", + "# 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;", @@ -203,77 +217,92 @@ export function buildExportScript(opts: { " 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;", - " for (const k of Object.keys(parsed)) { if (denyRe.test(k)) delete parsed[k]; }", - " if (parsed.env && typeof parsed.env === 'object') {", - " for (const k of Object.keys(parsed.env)) { if (denyRe.test(k)) delete parsed.env[k]; }", - " }", + " 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" "$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 ${shellQuote(opts.slug)} ${visibilityFlag} --source=. --push --description=${shellQuote("Exported with spawn")}`, + 'gh repo create "$SLUG" "$VISIBILITY_FLAG" --source=. --push --description="Exported with spawn"', "", - `printf '%s\\n' '{"slug":${JSON.stringify(opts.slug).replace(/'/g, "'\\''")},"url":"https://github.com/${opts.slug}"}' > ${shellQuote(opts.resultPath)}`, + "# 11. Emit the success result.", + 'printf \'{"ok":true,"slug":"%s","url":"https://github.com/%s"}\\n\' "$SLUG" "$SLUG" > "$RESULT_PATH"', "", ].join("\n"); } -/** Prompt for slug + visibility. */ -async function promptForRepo(record: SpawnRecord): Promise<{ - slug: string; - visibility: "private" | "public"; -} | null> { - const defaultName = sanitizeRepoName(record.name ?? record.connection?.server_name ?? "spawn-export"); - const slug = await p.text({ - message: "GitHub repo to export to (user/repo)", - placeholder: `your-username/${defaultName}`, - validate: (value) => { - if (!value) { - return "Required. Format: user/repo"; - } - if (!SLUG_PATTERN.test(value)) { - return "Must be in user/repo format with only [a-zA-Z0-9_.-]"; - } - const repoPart = value.split("/")[1]; - if (!REPO_NAME_PATTERN.test(repoPart)) { - return "Repo name must contain only [a-zA-Z0-9_.-]"; - } - return undefined; - }, - }); - if (p.isCancel(slug)) { - return null; - } +/** Single-quote a string for safe inclusion in a bash script. */ +function shSingleQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} - const visibility = await p.select({ - message: "Visibility", - options: [ - { - value: "private", - label: "Private", - hint: "default", - }, - { - value: "public", - label: "Public", - }, - ], - initialValue: "private", +/** Pick one record from a list of claude spawns. */ +async function pickOne(records: SpawnRecord[]): Promise { + 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(visibility)) { + if (p.isCancel(choice)) { return null; } - - return { - slug, - visibility: visibility === "public" ? "public" : "private", - }; + return records.find((r) => (r.id ?? r.timestamp) === choice) ?? null; } /** Options for cmdExport — injectable for testing. */ @@ -288,76 +317,52 @@ export interface ExportOptions { downloadFile: (remotePath: string, localPath: string) => Promise; uploadFile: (localPath: string, remotePath: string) => Promise; }; - /** Skip interactive prompts; supply repo info directly. */ - repo?: { - slug: string; - visibility: "private" | "public"; - }; + /** Override visibility (default 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 { - const record = resolveTarget(target, options?.records); - if (!record) { - if (target) { - p.log.error(`No claude spawn matches ${pc.bold(target)}.`); - p.log.info(`Run ${pc.cyan("spawn list -a claude")} to see available targets.`); - } else { - p.log.info("No claude spawns recorded yet."); - p.log.info(`Run ${pc.cyan("spawn claude ")} first, then export the result.`); - } - process.exit(1); - } - - if (record.agent !== CLAUDE_AGENT) { - p.log.error(`spawn export currently supports claude only (got ${pc.bold(record.agent)}).`); - p.log.info("Other agents will be added once their config introspection lands."); + 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 ")} first, then export the result.`); process.exit(1); } - const conn = record.connection; - if (!conn) { - p.log.error("Cannot export: spawn has no connection information."); - process.exit(1); - } - if (conn.deleted) { - p.log.error("Cannot export: server has been deleted."); - process.exit(1); - } - if (conn.ip === "sprite-console") { - p.log.error("Cannot export: Sprite-console connections aren't SSH-accessible."); - p.log.info("SSH directly into the VM and run `gh repo create` manually."); - process.exit(1); - } - - p.log.step(`Exporting ${pc.bold(buildRecordLabel(record))} ${pc.dim(`(${buildRecordSubtitle(record, null)})`)}`); - - // Resolve repo target - let repo = options?.repo; - if (!repo) { - if (!isInteractiveTTY()) { - p.log.error("spawn export requires --repo in non-interactive mode (not yet supported)."); + let record: SpawnRecord | null; + if (target) { + record = matchTarget(exportable, target); + if (!record) { + 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); } - const prompted = await promptForRepo(record); - if (!prompted) { + } else if (exportable.length === 1) { + record = exportable[0]; + } else { + record = await pickOne(exportable); + if (!record) { handleCancel(); } - repo = prompted!; } - // Build content - const spawnMd = buildSpawnMd(record); - const readme = buildReadme(record, repo.slug); - const gitignore = buildGitignore(); + // After the picker, record is guaranteed non-null (handleCancel exits). + const r: SpawnRecord = record!; + const conn = r.connection!; + + p.log.step(`Exporting ${pc.bold(buildRecordLabel(r))} ${pc.dim(`(${buildRecordSubtitle(r, null)})`)}`); + + const visibility = options?.visibility ?? "private"; const script = buildExportScript({ - spawnMd, - readme, - gitignore, - slug: repo.slug, - visibility: repo.visibility, + spawnMd: buildSpawnMd(r), + readmeTemplate: buildReadmeTemplate(), + gitignore: buildGitignore(), + cloud: r.cloud, + visibility, resultPath: REMOTE_RESULT_PATH, }); @@ -368,7 +373,7 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti : 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..."); + 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)}`); @@ -398,10 +403,14 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti 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} ${record.cloud} --repo ${parsed.slug}`)}`); + console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug}`)}`); console.log(); } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 129f56f5c..8c7917fc4 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -6,7 +6,7 @@ export { cascadeDelete, cmdDelete } from "./delete.js"; export { buildExportScript, buildGitignore, - buildReadme, + buildReadmeTemplate, buildSpawnMd, cmdExport, } from "./export.js"; From d7720e79be91ca70d6bff72aa4525c9c73fe35aa Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Fri, 1 May 2026 23:14:34 -0700 Subject: [PATCH 4/6] feat(export): default to public visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/cli/package.json | 2 +- packages/cli/src/commands/export.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9070c3738..f73a90ded 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.29", + "version": "1.0.30", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 4a1420d2c..6521ba475 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -356,7 +356,7 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti p.log.step(`Exporting ${pc.bold(buildRecordLabel(r))} ${pc.dim(`(${buildRecordSubtitle(r, null)})`)}`); - const visibility = options?.visibility ?? "private"; + const visibility = options?.visibility ?? "public"; const script = buildExportScript({ spawnMd: buildSpawnMd(r), readmeTemplate: buildReadmeTemplate(), From 656140b80deb3a46d442307b4555b47bf1d7aa7f Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Fri, 1 May 2026 23:22:33 -0700 Subject: [PATCH 5/6] feat(export): bake --steps into the spawn link for zero-prompt respawn The printed spawn link is now: spawn claude --repo --steps Source of the steps list: 1. Parse `--steps ` (or `--steps=`) 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. --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/export.test.ts | 57 ++++++++++++++++++++++- packages/cli/src/commands/export.ts | 43 +++++++++++++++-- packages/cli/src/commands/index.ts | 2 + 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index f73a90ded..386b6fc39 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.30", + "version": "1.0.31", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts index 0d08d19a5..514bd606d 100644 --- a/packages/cli/src/__tests__/export.test.ts +++ b/packages/cli/src/__tests__/export.test.ts @@ -5,7 +5,15 @@ mockClackPrompts(); import type { SpawnRecord } from "../history"; -import { buildExportScript, buildGitignore, buildReadmeTemplate, buildSpawnMd, cmdExport } from "../commands/export"; +import { + buildExportScript, + buildGitignore, + buildReadmeTemplate, + buildSpawnMd, + cmdExport, + parseStepsFromLaunchCmd, + resolveSteps, +} from "../commands/export"; import { parseSpawnMd } from "../shared/spawn-md"; const baseRecord: SpawnRecord = { @@ -69,7 +77,8 @@ describe("buildReadmeTemplate", () => { expect(tpl).toContain("__NAME__"); expect(tpl).toContain("__CLOUD__"); expect(tpl).toContain("__SLUG__"); - expect(tpl).toContain("spawn claude __CLOUD__ --repo __SLUG__"); + expect(tpl).toContain("__STEPS__"); + expect(tpl).toContain("spawn claude __CLOUD__ --repo __SLUG__ --steps __STEPS__"); }); it("renders a github-friendly checklist", () => { @@ -92,12 +101,50 @@ describe("buildGitignore", () => { }); }); +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(); + }); +}); + +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", }; @@ -179,6 +226,12 @@ describe("buildExportScript", () => { 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 ───────────────────────────────────────────────── diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 6521ba475..3cb06cfbd 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -34,6 +34,34 @@ 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 ` (or `--steps=`) 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; + } + const eq = cmd.match(/--steps=([^\s]+)/); + if (eq) { + return eq[1]; + } + const space = cmd.match(/--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([ @@ -128,7 +156,7 @@ export function buildGitignore(): string { } /** README template — the bash script substitutes __SLUG__, __CLOUD__, - * __NAME__ at runtime once claude has picked a name. */ + * __NAME__, __STEPS__ at runtime once claude has picked a name. */ export function buildReadmeTemplate(): string { return [ "# __NAME__", @@ -138,9 +166,12 @@ export function buildReadmeTemplate(): string { "## Quickstart", "", "```bash", - "spawn claude __CLOUD__ --repo __SLUG__", + "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", @@ -162,6 +193,7 @@ export function buildExportScript(opts: { readmeTemplate: string; gitignore: string; cloud: string; + steps: string; visibility: "private" | "public"; resultPath: string; }): string { @@ -172,6 +204,7 @@ export function buildExportScript(opts: { "", `RESULT_PATH=${shSingleQuote(opts.resultPath)}`, `CLOUD=${shSingleQuote(opts.cloud)}`, + `STEPS=${shSingleQuote(opts.steps)}`, `VISIBILITY_FLAG=${visibilityFlag}`, "", 'EXPORT_DIR="$(mktemp -d)"', @@ -255,7 +288,7 @@ export function buildExportScript(opts: { '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" "$EXPORT_DIR/README.md"', + '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"', @@ -357,11 +390,13 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti p.log.step(`Exporting ${pc.bold(buildRecordLabel(r))} ${pc.dim(`(${buildRecordSubtitle(r, null)})`)}`); const visibility = options?.visibility ?? "public"; + const steps = resolveSteps(r); const script = buildExportScript({ spawnMd: buildSpawnMd(r), readmeTemplate: buildReadmeTemplate(), gitignore: buildGitignore(), cloud: r.cloud, + steps, visibility, resultPath: REMOTE_RESULT_PATH, }); @@ -411,6 +446,6 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti 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}`)}`); + console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug} --steps ${steps}`)}`); console.log(); } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 8c7917fc4..e28883954 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -9,6 +9,8 @@ export { buildReadmeTemplate, buildSpawnMd, cmdExport, + parseStepsFromLaunchCmd, + resolveSteps, } from "./export.js"; // feedback.ts — cmdFeedback export { cmdFeedback } from "./feedback.js"; From b96e643f55bf372c1094fea11f4af5f479d82f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 06:32:50 +0000 Subject: [PATCH 6/6] fix(export): safe defaults and tighter flag parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/export.test.ts | 23 +++++++++ packages/cli/src/commands/export.ts | 59 +++++++++++++++++------ 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 386b6fc39..c137b229b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.31", + "version": "1.0.32", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts index 514bd606d..5bfc088ab 100644 --- a/packages/cli/src/__tests__/export.test.ts +++ b/packages/cli/src/__tests__/export.test.ts @@ -119,6 +119,13 @@ describe("parseStepsFromLaunchCmd", () => { // --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", () => { @@ -207,6 +214,22 @@ describe("buildExportScript", () => { 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, diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 3cb06cfbd..63756dd15 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -47,11 +47,12 @@ export function parseStepsFromLaunchCmd(cmd: string | undefined): string | null if (!cmd) { return null; } - const eq = cmd.match(/--steps=([^\s]+)/); + // 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(/--steps\s+([^\s]+)/); + const space = cmd.match(/(?:^|\s)--steps\s+([^\s]+)/); if (space) { return space[1]; } @@ -233,7 +234,7 @@ export function buildExportScript(opts: { 'mkdir -p "$EXPORT_DIR/claude"', "for d in skills commands hooks; do", ' if [ -d "$HOME/.claude/$d" ]; then', - ' rsync -a "$HOME/.claude/$d/" "$EXPORT_DIR/claude/$d/"', + ' rsync -a --exclude=.git "$HOME/.claude/$d/" "$EXPORT_DIR/claude/$d/"', " fi", "done", "for f in CLAUDE.md AGENTS.md settings.json; do", @@ -350,7 +351,8 @@ export interface ExportOptions { downloadFile: (remotePath: string, localPath: string) => Promise; uploadFile: (localPath: string, remotePath: string) => Promise; }; - /** Override visibility (default private). */ + /** 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[]; @@ -366,30 +368,55 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti process.exit(1); } - let record: SpawnRecord | null; + let picked: SpawnRecord | null = null; if (target) { - record = matchTarget(exportable, target); - if (!record) { + 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) { - record = exportable[0]; + picked = exportable[0] ?? null; } else { - record = await pickOne(exportable); - if (!record) { - handleCancel(); + picked = await pickOne(exportable); + if (!picked) { + handleCancel(); // never returns } } - - // After the picker, record is guaranteed non-null (handleCancel exits). - const r: SpawnRecord = record!; - const conn = r.connection!; + 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)})`)}`); - const visibility = options?.visibility ?? "public"; + // 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),