@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 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":"AAmHA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
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.11.0",
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",