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 +161 -3
- package/dist/board/port.d.ts +26 -0
- package/dist/board/port.d.ts.map +1 -0
- package/dist/board/port.js +41 -0
- package/dist/board/port.js.map +1 -0
- package/dist/cli/commands.d.ts +30 -1
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +118 -6
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/prompt.d.ts +26 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/prompt.js +48 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +24 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/help.d.ts +27 -2
- package/dist/commands/help.d.ts.map +1 -1
- package/dist/commands/help.js +175 -40
- package/dist/commands/help.js.map +1 -1
- package/dist/image/build.d.ts +28 -0
- package/dist/image/build.d.ts.map +1 -0
- package/dist/image/build.js +40 -0
- package/dist/image/build.js.map +1 -0
- package/package.json +2 -2
- package/scripts/build-factory-image.sh +10 -0
- package/skill/ghostrail/SKILL.md +5 -1
- package/templates/code-local/README.md +1 -1
- package/templates/content-loop/README.md +1 -1
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
|
|
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:
|
|
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"}
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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":"
|
|
1
|
+
{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/cli/commands.ts"],"names":[],"mappings":"AAqEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,8BAA8B,CAAC;AAE1D,OAAO,EAAE,KAAK,KAAK,EAAqC,MAAM,aAAa,CAAC;AAE5E,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,YAAY,GAAG,YAAY,CAc/F;AAED,UAAU,UAAU;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CACnC;AA2HD,iDAAiD;AACjD,wBAAsB,MAAM,CAC1B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,GAAG,SAAgB,GAClB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED,UAAU,UAAU;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,GAAG,SAAS,CAoB/E;AAED,sFAAsF;AACtF,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,GAAG,SAAgB,GAClB,OAAO,CAAC,MAAM,CAAC,CAgCjB;AAED,sFAAsF;AACtF,wBAAsB,SAAS,CAC7B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,GAAG,SAAgB,GAClB,OAAO,CAAC,MAAM,CAAC,CA+BjB;AAED,gFAAgF;AAChF,wBAAsB,UAAU,CAC9B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,GAAG,SAAgB,GAClB,OAAO,CAAC,MAAM,CAAC,CA4BjB;AAED,UAAU,UAAU;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,UAAU,GAAG,UAAU,CAwBzF;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,CACtB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,GAAG,SAAgB,GAClB,OAAO,CAAC,MAAM,CAAC,CAgEjB;AAED,sFAAsF;AACtF,wBAAsB,YAAY,CAChC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAejB;AAED,yDAAyD;AACzD,eAAO,MAAM,SAAS,yCAA0C,CAAC;AAyHjE;;;;;GAKG;AACH,wBAAsB,OAAO,CAC3B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,SAAgB,GAClB,OAAO,CAAC,MAAM,CAAC,CA8FjB;AAED,UAAU,UAAU;IAClB,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,OAAO,CAAC;IACnB,GAAG,EAAE,OAAO,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,CA8BnE;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,GAAG,SAAgB,GAClB,OAAO,CAAC,MAAM,CAAC,CAoDjB;AASD;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,GAAG,SAAgB,EACnB,IAAI,GAAE,WAAgB,GACrB,OAAO,CAAC,MAAM,CAAC,CAWjB;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,UAAU,EACf,IAAI,GAAE,WAAgB,GACrB,OAAO,CAAC,IAAI,CAAC,CA8Cf;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,MAAM,CAAC,CAcjB;AAgCD;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAoDjB;AAED,4EAA4E;AAC5E,MAAM,WAAW,WAAY,SAAQ,gBAAgB;IACnD,qEAAqE;IACrE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,+CAA+C;IAC/C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iEAAiE;IACjE,GAAG,CAAC,EAAE,KAAK,CAAC;CACb;AAED;;;;;;GAMG;AACH,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,IAAI,GAAE,WAAgB,GACrB,OAAO,CAAC,MAAM,CAAC,CAyBjB;AAwBD,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,GAAE,UAAwB,EAC7B,IAAI,GAAE,gBAAqB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAsCjB"}
|
package/dist/cli/commands.js
CHANGED
|
@@ -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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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 {
|