@clipboard-health/groundcrew 1.11.0 → 1.12.0
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 +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -0
- package/dist/commands/setupRepos.d.ts +44 -0
- package/dist/commands/setupRepos.d.ts.map +1 -0
- package/dist/commands/setupRepos.js +222 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,16 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
|
|
|
37
37
|
gh repo clone owner/repo ~/dev/groundcrew-workspaces/owner/repo
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
Or let `crew` clone every missing `owner/repo` entry for you using your `gh` login:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
crew setup repos # clone all missing entries
|
|
44
|
+
crew setup repos --dry-run # preview what would be cloned
|
|
45
|
+
crew setup repos owner/repo # restrict to one entry
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`crew setup repos` is idempotent — already-cloned repos are reported `[exists]` and untouched. Bare-name entries (no `owner/`) are skipped with an instruction to clone manually, since groundcrew can't safely guess the org. The command fails fast with an install hint when `gh` is not on `PATH`.
|
|
49
|
+
|
|
40
50
|
`crew` resolves the config path as: `GROUNDCREW_CONFIG` if set → `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/config.ts` if it exists → a `config.ts` sitting next to `crew`'s own source files (only useful from a local checkout; see [Hacking on groundcrew](#hacking-on-groundcrew)). Set `GROUNDCREW_CONFIG` only when you want to override the XDG location.
|
|
41
51
|
|
|
42
52
|
4. **Provide a Linear API key.** `crew` expects `LINEAR_API_KEY` in its environment. Any mechanism works — shell export, [direnv](https://direnv.net/), a `.env` file you `source`, or piping through `op run` if you store the credential in 1Password:
|
|
@@ -188,6 +198,7 @@ crew remote attach <session-id-or-command> --runner crew-claude-1
|
|
|
188
198
|
crew remote ps crew-claude-1
|
|
189
199
|
crew remote interrupt <process-group-id> --runner crew-claude-1
|
|
190
200
|
crew run --ticket <TICKET>
|
|
201
|
+
crew setup repos [--dry-run] [<repo>...]
|
|
191
202
|
crew cleanup <TICKET>
|
|
192
203
|
```
|
|
193
204
|
|
|
@@ -212,6 +223,7 @@ crew cleanup <TICKET>
|
|
|
212
223
|
- **Doctor checks every enabled model, including shipped defaults you didn't disable.** `models.definitions` includes both shipped defaults (`claude`, `codex`) by default via additive merge. If you only intend to label tickets `agent-claude` and don't have `codex` installed, set `models.definitions.codex: { disabled: true }` (see "Disabling a shipped default" under "Config reference"). Without that, doctor exits non-zero on a missing `codex` binary even though `crew run` would never route to it.
|
|
213
224
|
- **Switch to tmux if cmux is misbehaving.** Set `workspaceKind: "tmux"` to force the tmux backend when cmux's CLI/socket bridge is flaky (symptoms: `cmux --json list-workspaces` returning `Failed to write to socket (Broken pipe)` or `Socket not found at ...cmux.sock` on every tick). tmux is more reliable — just a unix socket, no GUI app — at the cost of losing cmux's status pills, notifications, and vertical-tab sidebar.
|
|
214
225
|
- **Agent CLI must accept a positional prompt.** The handoff is `<your cmd> "<prompt>"`. `claude`, `codex`, and `cursor-agent` all support this.
|
|
226
|
+
- **`crew setup repos` only auto-clones `owner/repo` entries.** Bare-name entries in `workspace.knownRepositories` (e.g. `"api"` rather than `"clipboardhealth/api"`) are skipped with a hint to clone manually — the command refuses to guess the owner. After a partial setup, the exit code is non-zero so CI gates notice; rerun is idempotent once you clone the bare ones into `<projectDir>/<name>` yourself. Adding a new repo to `knownRepositories` later? Just rerun `crew setup repos`; already-present entries report `[exists]` and are untouched.
|
|
215
227
|
|
|
216
228
|
## Hacking on groundcrew
|
|
217
229
|
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAsIA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,21 @@ import { cleanupWorkspaceCli } from "./commands/cleanupWorkspace.js";
|
|
|
3
3
|
import { doctor } from "./commands/doctor.js";
|
|
4
4
|
import { orchestrate } from "./commands/orchestrator.js";
|
|
5
5
|
import { remoteCli } from "./commands/remoteSetup.js";
|
|
6
|
+
import { setupReposCli } from "./commands/setupRepos.js";
|
|
6
7
|
import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
|
|
7
8
|
import { errorMessage, writeError, writeOutput } from "./lib/util.js";
|
|
8
9
|
const requireFromCli = createRequire(import.meta.url);
|
|
10
|
+
function setupUsage() {
|
|
11
|
+
return "Usage: crew setup repos [--dry-run] [<repo>...]";
|
|
12
|
+
}
|
|
13
|
+
async function setupCli(argv) {
|
|
14
|
+
const [verb, ...rest] = argv;
|
|
15
|
+
if (verb === "repos") {
|
|
16
|
+
await setupReposCli(rest);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
throw new Error(setupUsage());
|
|
20
|
+
}
|
|
9
21
|
async function runCli(argv) {
|
|
10
22
|
let watch = false;
|
|
11
23
|
let dryRun = false;
|
|
@@ -60,6 +72,11 @@ const SUBCOMMANDS = {
|
|
|
60
72
|
usage: "[--force] <ticket>",
|
|
61
73
|
invoke: cleanupWorkspaceCli,
|
|
62
74
|
},
|
|
75
|
+
setup: {
|
|
76
|
+
summary: "Project-level setup commands (currently: repos)",
|
|
77
|
+
usage: "repos [--dry-run] [<repo>...]",
|
|
78
|
+
invoke: setupCli,
|
|
79
|
+
},
|
|
63
80
|
remote: {
|
|
64
81
|
summary: "Create, authenticate, bootstrap, and inspect a remote runner",
|
|
65
82
|
usage: "setup <runner-name> [--claude] [--codex] [--datadog] [--github] [--mcp <alias|name=url>] [--checkpoint]\n" +
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crew setup repos` — clone every entry of `workspace.knownRepositories`
|
|
3
|
+
* that does not already exist under `workspace.projectDir`. Entries
|
|
4
|
+
* shaped `<owner>/<repo>` are cloned via `gh repo clone`; bare-name
|
|
5
|
+
* entries are skipped with a hint, because they have no canonical URL
|
|
6
|
+
* we can guess at without involving the user's gh login. Idempotent.
|
|
7
|
+
*/
|
|
8
|
+
import { type ResolvedConfig } from "../lib/config.ts";
|
|
9
|
+
export interface SetupReposOptions {
|
|
10
|
+
/** Print the plan without running any clone. */
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Restrict the action to this subset of `knownRepositories`. Each entry
|
|
14
|
+
* must match an entry in the config or the call rejects before any side
|
|
15
|
+
* effect.
|
|
16
|
+
*/
|
|
17
|
+
only?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export type SetupReposSkipKind = "bare-name" | "invalid-repository" | "invalid-target";
|
|
20
|
+
export interface SetupReposSkip {
|
|
21
|
+
repo: string;
|
|
22
|
+
kind: SetupReposSkipKind;
|
|
23
|
+
reason: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SetupReposResult {
|
|
26
|
+
/** Entries already present under `projectDir`. */
|
|
27
|
+
existing: string[];
|
|
28
|
+
/** Entries that would be cloned in dry-run mode. */
|
|
29
|
+
planned: string[];
|
|
30
|
+
/** Entries successfully cloned this run. */
|
|
31
|
+
cloned: string[];
|
|
32
|
+
/** Entries skipped with a reason (e.g. bare names, invalid targets). */
|
|
33
|
+
skipped: SetupReposSkip[];
|
|
34
|
+
/** Entries that failed during clone. */
|
|
35
|
+
failed: {
|
|
36
|
+
repo: string;
|
|
37
|
+
error: Error;
|
|
38
|
+
}[];
|
|
39
|
+
/** True when `gh` is missing and at least one clone was needed. */
|
|
40
|
+
ghMissing: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function setupRepos(config: ResolvedConfig, options: SetupReposOptions): Promise<SetupReposResult>;
|
|
43
|
+
export declare function setupReposCli(argv: string[]): Promise<void>;
|
|
44
|
+
//# sourceMappingURL=setupRepos.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setupRepos.d.ts","sourceRoot":"","sources":["../../src/commands/setupRepos.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAInE,MAAM,WAAW,iBAAiB;IAChC,gDAAgD;IAChD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAED,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,oBAAoB,GAAG,gBAAgB,CAAC;AAEvF,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,kBAAkB,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,kDAAkD;IAClD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,oDAAoD;IACpD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,4CAA4C;IAC5C,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,wEAAwE;IACxE,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,wCAAwC;IACxC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,EAAE,CAAC;IACzC,mEAAmE;IACnE,SAAS,EAAE,OAAO,CAAC;CACpB;AA6JD,wBAAsB,UAAU,CAC9B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,gBAAgB,CAAC,CAyD3B;AAwBD,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAajE"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crew setup repos` — clone every entry of `workspace.knownRepositories`
|
|
3
|
+
* that does not already exist under `workspace.projectDir`. Entries
|
|
4
|
+
* shaped `<owner>/<repo>` are cloned via `gh repo clone`; bare-name
|
|
5
|
+
* entries are skipped with a hint, because they have no canonical URL
|
|
6
|
+
* we can guess at without involving the user's gh login. Idempotent.
|
|
7
|
+
*/
|
|
8
|
+
import { opendirSync, statSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
10
|
+
import { runCommandAsync } from "../lib/commandRunner.js";
|
|
11
|
+
import { loadConfig } from "../lib/config.js";
|
|
12
|
+
import { which } from "../lib/host.js";
|
|
13
|
+
import { errorMessage, log, writeOutput } from "../lib/util.js";
|
|
14
|
+
function emptyResult() {
|
|
15
|
+
return {
|
|
16
|
+
existing: [],
|
|
17
|
+
planned: [],
|
|
18
|
+
cloned: [],
|
|
19
|
+
skipped: [],
|
|
20
|
+
failed: [],
|
|
21
|
+
ghMissing: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function selectRepositories(config, only) {
|
|
25
|
+
if (only === undefined) {
|
|
26
|
+
return config.workspace.knownRepositories;
|
|
27
|
+
}
|
|
28
|
+
const known = new Set(config.workspace.knownRepositories);
|
|
29
|
+
const unknown = only.filter((entry) => !known.has(entry));
|
|
30
|
+
if (unknown.length > 0) {
|
|
31
|
+
throw new Error(`Repositories not in workspace.knownRepositories: ${unknown.join(", ")}. Known: ${config.workspace.knownRepositories.join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
return only;
|
|
34
|
+
}
|
|
35
|
+
function pathExists(path) {
|
|
36
|
+
return statSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
37
|
+
}
|
|
38
|
+
function isDirectoryEmpty(path) {
|
|
39
|
+
const directory = opendirSync(path);
|
|
40
|
+
try {
|
|
41
|
+
return directory.readSync() === null;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
directory.closeSync();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function existingTargetPlan(target) {
|
|
48
|
+
const stats = statSync(target, { throwIfNoEntry: false });
|
|
49
|
+
if (stats === undefined) {
|
|
50
|
+
return "clone";
|
|
51
|
+
}
|
|
52
|
+
if (!stats.isDirectory()) {
|
|
53
|
+
return "skip-invalid";
|
|
54
|
+
}
|
|
55
|
+
if (pathExists(resolve(target, ".git"))) {
|
|
56
|
+
return "existing";
|
|
57
|
+
}
|
|
58
|
+
return isDirectoryEmpty(target) ? "clone" : "skip-invalid";
|
|
59
|
+
}
|
|
60
|
+
function isInsideProjectDir(projectDir, target) {
|
|
61
|
+
const relativeTarget = relative(projectDir, target);
|
|
62
|
+
return (relativeTarget.length > 0 && !relativeTarget.startsWith("..") && !isAbsolute(relativeTarget));
|
|
63
|
+
}
|
|
64
|
+
function repositoryEntryPlan(repo) {
|
|
65
|
+
const parts = repo.split("/");
|
|
66
|
+
if (parts.length === 1) {
|
|
67
|
+
return "bare-name";
|
|
68
|
+
}
|
|
69
|
+
if (parts.length === 2 && parts.every((part) => part.length > 0)) {
|
|
70
|
+
return "clone";
|
|
71
|
+
}
|
|
72
|
+
return "invalid-repository";
|
|
73
|
+
}
|
|
74
|
+
function bareNameSkip(repo, target) {
|
|
75
|
+
return {
|
|
76
|
+
repo,
|
|
77
|
+
kind: "bare-name",
|
|
78
|
+
reason: `bare name needs owner/ prefix to auto-clone; clone manually into ${target}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function invalidTargetSkip(repo, target) {
|
|
82
|
+
return {
|
|
83
|
+
repo,
|
|
84
|
+
kind: "invalid-target",
|
|
85
|
+
reason: `target exists but is not a git repository or empty directory: ${target}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function invalidRepositorySkip(repo, target) {
|
|
89
|
+
return {
|
|
90
|
+
repo,
|
|
91
|
+
kind: "invalid-repository",
|
|
92
|
+
reason: `repository must be owner/repo to auto-clone; clone manually into ${target}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function escapingTargetSkip(repo, projectDir, target) {
|
|
96
|
+
return {
|
|
97
|
+
repo,
|
|
98
|
+
kind: "invalid-repository",
|
|
99
|
+
reason: `repository resolves outside workspace.projectDir (${projectDir}): ${target}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function planClones(config, repositories) {
|
|
103
|
+
const projectDir = resolve(config.workspace.projectDir);
|
|
104
|
+
const toClone = [];
|
|
105
|
+
const existing = [];
|
|
106
|
+
const skipped = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const entry of repositories) {
|
|
109
|
+
if (seen.has(entry)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
seen.add(entry);
|
|
113
|
+
const target = resolve(projectDir, entry);
|
|
114
|
+
if (!isInsideProjectDir(projectDir, target)) {
|
|
115
|
+
skipped.push(escapingTargetSkip(entry, projectDir, target));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const targetPlan = existingTargetPlan(target);
|
|
119
|
+
if (targetPlan === "existing") {
|
|
120
|
+
existing.push(entry);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (targetPlan === "skip-invalid") {
|
|
124
|
+
skipped.push(invalidTargetSkip(entry, target));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const repositoryPlan = repositoryEntryPlan(entry);
|
|
128
|
+
if (repositoryPlan === "bare-name") {
|
|
129
|
+
skipped.push(bareNameSkip(entry, target));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (repositoryPlan === "invalid-repository") {
|
|
133
|
+
skipped.push(invalidRepositorySkip(entry, target));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
toClone.push(entry);
|
|
137
|
+
}
|
|
138
|
+
return { toClone, existing, skipped };
|
|
139
|
+
}
|
|
140
|
+
export async function setupRepos(config, options) {
|
|
141
|
+
const repositories = selectRepositories(config, options.only);
|
|
142
|
+
const plan = planClones(config, repositories);
|
|
143
|
+
const result = emptyResult();
|
|
144
|
+
result.existing = plan.existing;
|
|
145
|
+
result.skipped = plan.skipped;
|
|
146
|
+
for (const entry of plan.existing) {
|
|
147
|
+
log(`[exists] ${entry}`);
|
|
148
|
+
}
|
|
149
|
+
for (const { repo, reason } of plan.skipped) {
|
|
150
|
+
log(`[skip] ${repo} — ${reason}`);
|
|
151
|
+
}
|
|
152
|
+
if (options.dryRun === true) {
|
|
153
|
+
result.planned = plan.toClone;
|
|
154
|
+
for (const entry of plan.toClone) {
|
|
155
|
+
log(`[dry-run] would clone ${entry}`);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
if (plan.toClone.length === 0) {
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
const ghPath = await which("gh");
|
|
163
|
+
if (ghPath === undefined) {
|
|
164
|
+
result.ghMissing = true;
|
|
165
|
+
writeOutput("gh CLI not found - install GitHub CLI from https://cli.github.com/ (or clone the missing repos manually).");
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
const projectDir = resolve(config.workspace.projectDir);
|
|
169
|
+
// Sequential on purpose: each `gh repo clone` inherits stdio for progress
|
|
170
|
+
// bars and auth prompts. Parallel clones would interleave output and make
|
|
171
|
+
// any interactive 2FA prompt unanswerable.
|
|
172
|
+
for (const entry of plan.toClone) {
|
|
173
|
+
const target = resolve(projectDir, entry);
|
|
174
|
+
log(`[clone] ${entry} → ${target}`);
|
|
175
|
+
try {
|
|
176
|
+
// oxlint-disable-next-line no-await-in-loop -- see comment above
|
|
177
|
+
await runCommandAsync("gh", ["repo", "clone", entry, target], {
|
|
178
|
+
stdio: "inherit",
|
|
179
|
+
timeoutMs: 0,
|
|
180
|
+
});
|
|
181
|
+
result.cloned.push(entry);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const wrapped = error instanceof Error ? error : new Error(errorMessage(error));
|
|
185
|
+
log(`[fail] ${entry}: ${wrapped.message}`);
|
|
186
|
+
result.failed.push({ repo: entry, error: wrapped });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function parseArguments(argv) {
|
|
192
|
+
let dryRun = false;
|
|
193
|
+
const positionals = [];
|
|
194
|
+
for (const argument of argv) {
|
|
195
|
+
if (argument === "--dry-run") {
|
|
196
|
+
dryRun = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (argument.startsWith("-")) {
|
|
200
|
+
throw new Error(`Unknown option: ${argument}\nUsage: crew setup repos [--dry-run] [<repo>...]`);
|
|
201
|
+
}
|
|
202
|
+
positionals.push(argument);
|
|
203
|
+
}
|
|
204
|
+
const options = { dryRun };
|
|
205
|
+
if (positionals.length > 0) {
|
|
206
|
+
options.only = positionals;
|
|
207
|
+
}
|
|
208
|
+
return options;
|
|
209
|
+
}
|
|
210
|
+
export async function setupReposCli(argv) {
|
|
211
|
+
const options = parseArguments(argv);
|
|
212
|
+
const config = await loadConfig();
|
|
213
|
+
const result = await setupRepos(config, options);
|
|
214
|
+
if (result.ghMissing || result.failed.length > 0) {
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Remaining skips mean setup is incomplete — signal that to CI gates.
|
|
219
|
+
if (result.skipped.length > 0) {
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clipboard-health/groundcrew",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle, remote runners, and usage tracking.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|