ghostrail 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,14 +19,172 @@ A software factory is thin glue over three primitive layers: where work comes fr
19
19
  ## Quickstart
20
20
 
21
21
  ```bash
22
- npx ghostrail install # CLI on your PATH + the /ghostrail agent skill
22
+ npx ghostrail install # CLI + skill, then offers to set up your credentials
23
23
  ghostrail init code-local # or: content-loop
24
- ghostrail secrets-setup --write # scaffold your credentials file, then fill it in
25
24
  ghostrail run # one tick: claim, isolate, run, gate, open PRs
26
25
  ghostrail respond # fold new human PR comments back into branches
27
- ghostrail board # watch runs at http://127.0.0.1:4369/
26
+ ghostrail board # watch runs at http://127.0.0.1:5050/
28
27
  ```
29
28
 
29
+ ## Credentials
30
+
31
+ Ghostrail reads its credentials from two dotenv-style files before every `run`,
32
+ `watch`, `respond`, and `triage`:
33
+
34
+ | File | Scope |
35
+ | --- | --- |
36
+ | `~/.config/ghostrail/secrets.env` | every factory on your account |
37
+ | `.ghostrail.env` in the repo you point `-C` at | that one factory |
38
+
39
+ Precedence is repo, then global, then the ambient environment, so a value in a
40
+ file beats an exported variable of the same name. Nothing has to go in your
41
+ shell profile.
42
+
43
+ `ghostrail install` offers to create this file for you at the end of a fresh
44
+ install. You can also do it any time:
45
+
46
+ ```bash
47
+ ghostrail secrets-setup # report what it would add, change nothing
48
+ ghostrail secrets-setup --write # create it (mode 600), then open your $EDITOR
49
+ ```
50
+
51
+ It is append-only and safe to re-run: existing values are never rewritten, keys
52
+ you added yourself are left alone, and a second run adds nothing. Run it again
53
+ after upgrading and it tops the file up with placeholders for any credential a
54
+ newer ghostrail has learned to read. Add `--no-edit` to skip the editor.
55
+
56
+ Uncomment only the lines you need. Leave the rest commented, because an
57
+ uncommented empty value overrides, and therefore blanks out, a working
58
+ credential from your environment.
59
+
60
+ ### `LINEAR_API_KEY` (required)
61
+
62
+ The tracker credential. Ghostrail acts as whoever owns this key: it reads
63
+ eligible issues, assigns them, moves their state, comments, and creates
64
+ sub-issues during `triage`.
65
+
66
+ 1. Go to [linear.app/settings/account/security](https://linear.app/settings/account/security)
67
+ (**Settings → Account → Security & access**).
68
+ 2. Under **Personal API keys**, click **New API key**.
69
+ 3. Name it something you will recognize later, like `ghostrail`.
70
+ 4. Grant **Write**. Read alone is not enough: ghostrail assigns issues, moves
71
+ them between states, and comments. You do not need **Admin**.
72
+ 5. Restrict it to the team your factory works in, if your workspace has several.
73
+ 6. Copy the key (it starts with `lin_api_`) into `LINEAR_API_KEY`.
74
+
75
+ Linear shows the key once. If you lose it, delete that key and make a new one.
76
+
77
+ ### `GH_TOKEN` (required for a code factory)
78
+
79
+ Used to clone the repo, push factory branches, open pull requests, label them,
80
+ and read and post PR comments during `respond`. `GITHUB_TOKEN` is read as a
81
+ fallback when `GH_TOKEN` is unset; set one, not both.
82
+
83
+ **The simple option, a classic token.** Go to
84
+ [github.com/settings/tokens](https://github.com/settings/tokens) → **Generate
85
+ new token (classic)**, set an expiration (90 days is a reasonable default), and
86
+ tick the single **`repo`** scope. That covers everything ghostrail does. Add
87
+ **`workflow`** only if you expect the agent to edit files under
88
+ `.github/workflows/`, since GitHub rejects those pushes otherwise.
89
+
90
+ **The tighter option, a fine-grained token.** Go to
91
+ [github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens)
92
+ → **Generate new token**, set an expiration, select **Only select
93
+ repositories** and pick the repo your factory targets, then grant these
94
+ repository permissions:
95
+
96
+ | Permission | Access | Why |
97
+ | --- | --- | --- |
98
+ | Contents | Read and write | clone the repo, push the factory branch |
99
+ | Pull requests | Read and write | open the PR, read its comments in `respond` |
100
+ | Issues | Read and write | apply the `ghostrail` label, post PR comments |
101
+ | Metadata | Read-only | mandatory, GitHub enables it for you |
102
+
103
+ Issues is the surprising one. PR labels and PR comments both go through
104
+ GitHub's issues API, so a token with only Pull requests write cannot label a PR
105
+ or comment on it.
106
+
107
+ One caveat before you choose fine-grained: `gh` has a
108
+ [known issue](https://github.com/cli/cli/issues/9166) where some PR reads fail
109
+ with `Resource not accessible by personal access token`, because the CLI asks
110
+ for project-card fields no fine-grained permission covers. If `respond` fails
111
+ that way, a classic `repo` token is the fix.
112
+
113
+ For a repo owned by an organization, an org owner may need to approve the token
114
+ before it works, and fine-grained tokens can be blocked by org policy entirely.
115
+
116
+ ### Agent auth: pick one rail
117
+
118
+ Ghostrail runs Claude Code as the coding agent, and there are two ways to pay
119
+ for it. `ANTHROPIC_API_KEY` **wins whenever both are set**, so if you mean to
120
+ bill a subscription, leave the API key commented out and make sure it is not
121
+ exported in your shell either.
122
+
123
+ **`CLAUDE_CODE_OAUTH_TOKEN`, to bill a Pro or Max subscription.** Run:
124
+
125
+ ```bash
126
+ claude setup-token
127
+ ```
128
+
129
+ It opens the same browser authorization flow as `/login` and prints a one-year
130
+ token to the terminal. It does not save it anywhere, so copy it straight into
131
+ `CLAUDE_CODE_OAUTH_TOKEN`. Requires a Pro, Max, Team, or Enterprise plan.
132
+ Confirm that automated use fits your plan's terms before running it unattended,
133
+ and note that your plan's rolling limits are shared with your claude.ai usage.
134
+
135
+ **`ANTHROPIC_API_KEY`, to bill metered API usage.** Go to
136
+ [platform.claude.com](https://platform.claude.com) → **Settings → API keys** →
137
+ **Create key**, and copy it into `ANTHROPIC_API_KEY`. Straightforward billing
138
+ and no shared subscription limits, but every run costs tokens.
139
+
140
+ ### Keeping them safe
141
+
142
+ `secrets-setup` writes the file mode `600` inside a `700` directory, so it is
143
+ readable only by you. A few things worth knowing:
144
+
145
+ - **Never commit one.** A repo-local `.ghostrail.env` belongs in `.gitignore`;
146
+ the scaffolded templates do not add that line for you.
147
+ - **Ghostrail redacts values from its CLI output.** Run logs and the JSON a tick
148
+ prints replace any known credential value, and `secrets-setup` prints key
149
+ names only. The run store behind `ghostrail board` keeps metadata rather than
150
+ command output (gate step names, exit codes, PR links), so it has little to
151
+ leak, but it is not passed through the redactor. Treat redaction as a safety
152
+ net, not a reason to paste a key into a terminal.
153
+ - **Rotate on the provider, not in the file.** Delete the old key at the source
154
+ so a leaked copy stops working, then paste the new one in.
155
+
156
+ ## The factory image
157
+
158
+ Only the `container` backend needs one. Both scaffold templates default to
159
+ `local-worktree`, which runs in a git worktree on your host with no image and no
160
+ Docker, so a first setup usually needs nothing here.
161
+
162
+ When you do switch to `kind = "container"`, build the toolchain image once per
163
+ machine. The agent and the gate run *inside* the workspace, so the container
164
+ needs node and pnpm for the gate, git and `gh` to publish, and the Claude CLI for
165
+ the agent. The default `alpine:3` has none of them.
166
+
167
+ ```bash
168
+ ghostrail image build # -> ghostrail-factory:local
169
+ ghostrail image build --tag mine:v2 # or your own tag
170
+ ```
171
+
172
+ The Dockerfile ships inside the npm package, so this needs no checkout. From a
173
+ repo checkout, `scripts/build-factory-image.sh` does the same thing. `ghostrail
174
+ install` offers to run the build for you, defaulting to no since most people
175
+ start on `local-worktree`.
176
+
177
+ Point the config at whatever you built:
178
+
179
+ ```toml
180
+ [backend]
181
+ kind = "container"
182
+ image = "ghostrail-factory:local"
183
+ ```
184
+
185
+ No credentials are baked into the image. They are forwarded per run from your
186
+ environment after the secrets files are applied.
187
+
30
188
  ## Drive it from your coding agent
31
189
 
32
190
  `ghostrail skill` installs a `/ghostrail` skill into whichever coding agents you
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The board's default port.
3
+ *
4
+ * It was 4369 until TJ-1507, which is the IANA-registered port for EPMD, the
5
+ * Erlang Port Mapper Daemon. Any machine running RabbitMQ, CouchDB, or Elixir
6
+ * tooling already holds it, so the board failed to start through no fault of
7
+ * the user. 5050 is unassigned.
8
+ */
9
+ export declare const DEFAULT_BOARD_PORT = 5050;
10
+ /** How many ports to try before giving up, when the port was not named. */
11
+ export declare const PORT_FALLBACK_ATTEMPTS = 20;
12
+ /**
13
+ * The ports to try, in order: `start`, `start + 1`, and so on. Stops at the top
14
+ * of the port range rather than proposing invalid ports, so a start near the
15
+ * ceiling simply yields a shorter list. Pure.
16
+ */
17
+ export declare function portCandidates(start: number, attempts: number): number[];
18
+ /**
19
+ * True for a value that can actually be bound: a whole number in 1..65535.
20
+ * Guards the flag, so `--port -5` or `--port 99999` is rejected with a clear
21
+ * message rather than reaching `listen` and failing obscurely.
22
+ */
23
+ export declare function isValidPort(value: number): boolean;
24
+ /** True for the error a busy port produces, the only one worth retrying. */
25
+ export declare function isAddressInUse(error: unknown): boolean;
26
+ //# sourceMappingURL=port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../src/board/port.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,2EAA2E;AAC3E,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAKzC;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAQxE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEtD"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The board's default port.
3
+ *
4
+ * It was 4369 until TJ-1507, which is the IANA-registered port for EPMD, the
5
+ * Erlang Port Mapper Daemon. Any machine running RabbitMQ, CouchDB, or Elixir
6
+ * tooling already holds it, so the board failed to start through no fault of
7
+ * the user. 5050 is unassigned.
8
+ */
9
+ export const DEFAULT_BOARD_PORT = 5050;
10
+ /** How many ports to try before giving up, when the port was not named. */
11
+ export const PORT_FALLBACK_ATTEMPTS = 20;
12
+ /** The highest valid TCP port. */
13
+ const MAX_PORT = 65535;
14
+ /**
15
+ * The ports to try, in order: `start`, `start + 1`, and so on. Stops at the top
16
+ * of the port range rather than proposing invalid ports, so a start near the
17
+ * ceiling simply yields a shorter list. Pure.
18
+ */
19
+ export function portCandidates(start, attempts) {
20
+ const ports = [];
21
+ for (let i = 0; i < attempts; i++) {
22
+ const port = start + i;
23
+ if (port > MAX_PORT)
24
+ break;
25
+ ports.push(port);
26
+ }
27
+ return ports;
28
+ }
29
+ /**
30
+ * True for a value that can actually be bound: a whole number in 1..65535.
31
+ * Guards the flag, so `--port -5` or `--port 99999` is rejected with a clear
32
+ * message rather than reaching `listen` and failing obscurely.
33
+ */
34
+ export function isValidPort(value) {
35
+ return Number.isInteger(value) && value >= 1 && value <= MAX_PORT;
36
+ }
37
+ /** True for the error a busy port produces, the only one worth retrying. */
38
+ export function isAddressInUse(error) {
39
+ return error?.code === "EADDRINUSE";
40
+ }
41
+ //# sourceMappingURL=port.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"port.js","sourceRoot":"","sources":["../../src/board/port.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEvC,2EAA2E;AAC3E,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAEzC,kCAAkC;AAClC,MAAM,QAAQ,GAAG,KAAK,CAAC;AAEvB;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,KAAa,EAAE,QAAgB;IAC5D,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC;QAClC,MAAM,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC;QACvB,IAAI,IAAI,GAAG,QAAQ;YAAE,MAAM;QAC3B,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,OAAO,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI,QAAQ,CAAC;AACpE,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAQ,KAAmC,EAAE,IAAI,KAAK,YAAY,CAAC;AACrE,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import type { Scope } from "../vendor/skilltend/paths.js";
2
+ import { type Asker } from "./prompt.js";
2
3
  export interface CommandStreams {
3
4
  out: (text: string) => void;
4
5
  err: (text: string) => void;
@@ -39,6 +40,8 @@ interface BoardFlags {
39
40
  port: number;
40
41
  bind: string;
41
42
  token?: string;
43
+ /** True when --port/-p named the port, rather than it coming from the default. */
44
+ portExplicit: boolean;
42
45
  }
43
46
  /** Parse `board` flags: `--repo`, `--port`, `--bind`, `--token`. */
44
47
  export declare function parseBoardFlags(argv: readonly string[], defaults: BoardFlags): BoardFlags;
@@ -84,7 +87,16 @@ export declare function cmdSkill(argv: readonly string[], streams: CommandStream
84
87
  * Pins to the running version rather than `latest`, so `npx ghostrail@x.y.z
85
88
  * install` gives you exactly x.y.z.
86
89
  */
87
- export declare function cmdInstall(argv: readonly string[], streams: CommandStreams, env?: CommandEnv, cwd?: string): Promise<number>;
90
+ export declare function cmdInstall(argv: readonly string[], streams: CommandStreams, env?: CommandEnv, cwd?: string, deps?: InstallDeps): Promise<number>;
91
+ /**
92
+ * Offer the two setup steps `install` cannot infer: the factory image and the
93
+ * credentials file. An explicit flag decides; otherwise ask, but only on a TTY,
94
+ * so a Dockerfile or CI install never blocks on stdin.
95
+ *
96
+ * Failures here are reported and swallowed. A failed optional extra must not
97
+ * make a successful install look like a failed one.
98
+ */
99
+ export declare function runInstallOffers(argv: readonly string[], streams: CommandStreams, env: CommandEnv, deps?: InstallDeps): Promise<void>;
88
100
  /**
89
101
  * `ghostrail self-update`: upgrade the CLI to the latest published version, then
90
102
  * install that version's skill.
@@ -101,6 +113,23 @@ export declare function cmdSelfUpdate(argv: readonly string[], streams: CommandS
101
113
  * the profile.
102
114
  */
103
115
  export declare function cmdClaudeSetup(argv: readonly string[], streams: CommandStreams, env?: CommandEnv): Promise<number>;
116
+ /** Injectables so `image` and the install offers can be driven in tests. */
117
+ export interface InstallDeps extends SecretsSetupDeps {
118
+ /** Runs docker; defaults to spawning it and streaming its output. */
119
+ runDocker?: (args: readonly string[]) => Promise<number>;
120
+ /** Overrides TTY detection for the prompts. */
121
+ isTty?: boolean;
122
+ /** Answers a yes/no prompt; defaults to reading the terminal. */
123
+ ask?: Asker;
124
+ }
125
+ /**
126
+ * `ghostrail image build`: build the factory toolchain image from the
127
+ * Dockerfile inside the installed package, so no repo checkout is needed.
128
+ *
129
+ * Only `build` is supported today; the noun is a subcommand so `image push` or
130
+ * `image check` can join it later without another top-level verb.
131
+ */
132
+ export declare function cmdImage(argv: readonly string[], streams: CommandStreams, deps?: InstallDeps): Promise<number>;
104
133
  /** Injectables so the editor launch can be observed instead of performed. */
105
134
  export interface SecretsSetupDeps {
106
135
  launchEditor?: (command: readonly string[], path: string) => Promise<number>;
@@ -1 +1 @@
1
- {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/cli/commands.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/cli/commands.ts"],"names":[],"mappings":"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"}
@@ -1,15 +1,18 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, isAbsolute, join, sep } from "node:path";
4
5
  import process from "node:process";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { spawnCollect } from "../backend/proc.js";
8
+ import { DEFAULT_BOARD_PORT, PORT_FALLBACK_ATTEMPTS, isAddressInUse, isValidPort, portCandidates, } from "../board/port.js";
7
9
  import { createBoardServer } from "../board/server.js";
8
10
  import { buildProposal, writeProfile } from "../claude-profile/index.js";
9
11
  import { parseDuration } from "../config/duration.js";
10
12
  import { parseMergedConfig } from "../config/load.js";
11
13
  import { STORE_DIR, buildLoopDeps, buildRespondDeps, buildTriageDeps, resolveRepoSlug, } from "../factory/build.js";
12
14
  import { GlobalStore, resolveGlobalDir } from "../global/store.js";
15
+ import { DEFAULT_FACTORY_IMAGE, dockerBuildArgs, dockerDir, parseImageFlags, } from "../image/build.js";
13
16
  import { classify, hashContent, mergeManifest, readManifest, writeManifest, } from "../init/template.js";
14
17
  import { FileLock } from "../lock/file-lock.js";
15
18
  import { runTick } from "../loop/engine.js";
@@ -20,6 +23,7 @@ import { applySkillPlan, describePlan, pathCtx, planSkillInstall, uninstallSkill
20
23
  import { FileStore } from "../store/file-store.js";
21
24
  import { triageTick } from "../triage/engine.js";
22
25
  import { VERSION } from "../version.js";
26
+ import { askOnTerminal, parseInstallOffers } from "./prompt.js";
23
27
  /**
24
28
  * Parse the flags shared by `run` and `respond`: `--config`/`-c <path>` and
25
29
  * `--repo`/`-C <path>`, starting from the given defaults. Unknown tokens are
@@ -268,8 +272,10 @@ export function parseBoardFlags(argv, defaults) {
268
272
  i++;
269
273
  }
270
274
  else if (arg === "--port" || arg === "-p") {
271
- if (Number.isFinite(Number(next)))
275
+ if (isValidPort(Number(next))) {
272
276
  flags.port = Number(next);
277
+ flags.portExplicit = true;
278
+ }
273
279
  i++;
274
280
  }
275
281
  else if (arg === "--bind") {
@@ -287,10 +293,16 @@ export function parseBoardFlags(argv, defaults) {
287
293
  export function cmdBoard(argv, streams, env = process.env, cwd = process.cwd()) {
288
294
  const flags = parseBoardFlags(argv, {
289
295
  repo: cwd,
290
- port: 4369,
296
+ port: DEFAULT_BOARD_PORT,
291
297
  bind: "127.0.0.1",
298
+ portExplicit: false,
292
299
  ...(env.GHOSTRAIL_BOARD_TOKEN === undefined ? {} : { token: env.GHOSTRAIL_BOARD_TOKEN }),
293
300
  });
301
+ // A --port that failed validation leaves the default in place. Say so, rather
302
+ // than quietly serving somewhere the user did not ask for.
303
+ if (!flags.portExplicit && (argv.includes("--port") || argv.includes("-p"))) {
304
+ streams.err("ignoring --port: must be a whole number from 1 to 65535\n");
305
+ }
294
306
  const store = new FileStore(join(flags.repo, STORE_DIR));
295
307
  let server;
296
308
  try {
@@ -303,13 +315,40 @@ export function cmdBoard(argv, streams, env = process.env, cwd = process.cwd())
303
315
  streams.err(`${error instanceof Error ? error.message : String(error)}\n`);
304
316
  return Promise.resolve(1);
305
317
  }
318
+ const candidates = flags.portExplicit
319
+ ? [flags.port]
320
+ : portCandidates(flags.port, PORT_FALLBACK_ATTEMPTS);
306
321
  return new Promise((resolve) => {
322
+ let index = 0;
307
323
  server.on("error", (error) => {
308
- streams.err(`board error: ${error.message}\n`);
324
+ // Only a busy port is worth retrying, and only when we chose the port
325
+ // ourselves. A port the user named is never moved silently, because
326
+ // something (a bookmark, a proxy) usually depends on that exact number.
327
+ const next = candidates[index + 1];
328
+ if (isAddressInUse(error) && next !== undefined) {
329
+ index++;
330
+ server.listen(next, flags.bind);
331
+ return;
332
+ }
333
+ if (isAddressInUse(error) && flags.portExplicit) {
334
+ streams.err(`board error: port ${flags.port} is already in use\n`);
335
+ }
336
+ else if (isAddressInUse(error)) {
337
+ const last = candidates[candidates.length - 1] ?? flags.port;
338
+ streams.err(`board error: no free port between ${flags.port} and ${last}\n`);
339
+ }
340
+ else {
341
+ streams.err(`board error: ${error.message}\n`);
342
+ }
309
343
  resolve(1);
310
344
  });
311
- server.listen(flags.port, flags.bind, () => {
312
- streams.out(`ghostrail board on http://${flags.bind}:${flags.port}/\n`);
345
+ const first = candidates[0] ?? flags.port;
346
+ server.listen(first, flags.bind, () => {
347
+ const bound = candidates[index] ?? first;
348
+ if (bound !== flags.port) {
349
+ streams.out(`port ${flags.port} was busy, using ${bound}\n`);
350
+ }
351
+ streams.out(`ghostrail board on http://${flags.bind}:${bound}/\n`);
313
352
  });
314
353
  });
315
354
  }
@@ -630,15 +669,53 @@ async function npmInstallGlobal(version) {
630
669
  * Pins to the running version rather than `latest`, so `npx ghostrail@x.y.z
631
670
  * install` gives you exactly x.y.z.
632
671
  */
633
- export async function cmdInstall(argv, streams, env = process.env, cwd = process.cwd()) {
672
+ export async function cmdInstall(argv, streams, env = process.env, cwd = process.cwd(), deps = {}) {
634
673
  streams.out(`installing ghostrail@${VERSION} globally...\n`);
635
674
  const code = await npmInstallGlobal(VERSION);
636
675
  if (code !== 0) {
637
676
  streams.err(`global install failed (npm exited ${code}). Install it yourself with: npm install -g ghostrail\n`);
638
677
  }
639
678
  const skillCode = await cmdSkill(argv, streams, env, cwd);
679
+ await runInstallOffers(argv, streams, env, deps);
640
680
  return code === 0 ? skillCode : 1;
641
681
  }
682
+ /**
683
+ * Offer the two setup steps `install` cannot infer: the factory image and the
684
+ * credentials file. An explicit flag decides; otherwise ask, but only on a TTY,
685
+ * so a Dockerfile or CI install never blocks on stdin.
686
+ *
687
+ * Failures here are reported and swallowed. A failed optional extra must not
688
+ * make a successful install look like a failed one.
689
+ */
690
+ export async function runInstallOffers(argv, streams, env, deps = {}) {
691
+ const offers = parseInstallOffers(argv);
692
+ const interactive = deps.isTty ?? process.stdin.isTTY === true;
693
+ const ask = deps.ask ?? askOnTerminal;
694
+ const decide = async (stated, question, defaultYes) => {
695
+ if (stated !== undefined)
696
+ return stated;
697
+ if (!interactive)
698
+ return false;
699
+ return ask(question, defaultYes);
700
+ };
701
+ const wantsImage = await decide(offers.image, `\nBuild the factory container image (${DEFAULT_FACTORY_IMAGE})?\nOnly needed for [backend].kind = "container", and it takes a few minutes.`, false);
702
+ if (wantsImage) {
703
+ const code = await cmdImage(["build"], streams, deps);
704
+ if (code !== 0)
705
+ streams.err("image build failed; run `ghostrail image build` to retry\n");
706
+ }
707
+ const secretsPath = join(resolveGlobalDir({ GHOSTRAIL_HOME: env.GHOSTRAIL_HOME, XDG_CONFIG_HOME: env.XDG_CONFIG_HOME }, homedir()), GLOBAL_SECRETS_FILE);
708
+ const wantsSecrets = await decide(offers.secrets, `\nCreate your credentials (secrets) file at ${secretsPath}?`, true);
709
+ if (wantsSecrets) {
710
+ const code = await cmdSecretsSetup(["--write"], streams, env, {
711
+ ...(deps.isTty === undefined ? {} : { isTty: deps.isTty }),
712
+ ...(deps.launchEditor === undefined ? {} : { launchEditor: deps.launchEditor }),
713
+ });
714
+ if (code !== 0) {
715
+ streams.err("secrets setup failed; run `ghostrail secrets-setup --write` to retry\n");
716
+ }
717
+ }
718
+ }
642
719
  /**
643
720
  * `ghostrail self-update`: upgrade the CLI to the latest published version, then
644
721
  * install that version's skill.
@@ -734,6 +811,41 @@ export async function cmdClaudeSetup(argv, streams, env = process.env) {
734
811
  }
735
812
  return 0;
736
813
  }
814
+ /**
815
+ * `ghostrail image build`: build the factory toolchain image from the
816
+ * Dockerfile inside the installed package, so no repo checkout is needed.
817
+ *
818
+ * Only `build` is supported today; the noun is a subcommand so `image push` or
819
+ * `image check` can join it later without another top-level verb.
820
+ */
821
+ export async function cmdImage(argv, streams, deps = {}) {
822
+ const [subcommand, ...rest] = argv;
823
+ if (subcommand !== "build") {
824
+ streams.err(`ghostrail image: unknown subcommand '${subcommand ?? ""}'\n\nUsage: ghostrail image build [--tag <tag>]\n`);
825
+ return 1;
826
+ }
827
+ const { tag } = parseImageFlags(rest);
828
+ const context = dockerDir();
829
+ const dockerfile = join(context, "factory.Dockerfile");
830
+ const args = dockerBuildArgs(dockerfile, context, tag);
831
+ streams.out(`building ${tag} from ${dockerfile}\n`);
832
+ const run = deps.runDocker ?? streamDocker;
833
+ const code = await run(args);
834
+ if (code === 0) {
835
+ streams.out(`built ${tag}\n`);
836
+ return 0;
837
+ }
838
+ streams.err(`docker exited ${code}. Is Docker installed and running? \`docker version\` should succeed.\n`);
839
+ return code;
840
+ }
841
+ /** Spawn docker inheriting the terminal, so a long build shows progress. */
842
+ function streamDocker(args) {
843
+ return new Promise((resolve) => {
844
+ const child = spawn("docker", [...args], { stdio: "inherit" });
845
+ child.on("error", () => resolve(1));
846
+ child.on("close", (code) => resolve(code ?? 1));
847
+ });
848
+ }
737
849
  /** Parse `secrets-setup` flags: `--write` and `--no-edit`. */
738
850
  function parseSecretsSetupFlags(argv) {
739
851
  return {