@clipboard-health/groundcrew 1.10.2 → 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:
@@ -81,6 +91,7 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
81
91
  --claude \
82
92
  --codex \
83
93
  --copy-local-codex-auth \
94
+ --datadog \
84
95
  --github \
85
96
  --git-name "Your Name" \
86
97
  --git-email "you@users.noreply.github.com" \
@@ -89,7 +100,7 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
89
100
  --checkpoint
90
101
  ```
91
102
 
92
- Known MCP aliases are `linear`, `slack`, and `notion`. For another HTTP MCP server, pass `--mcp name=https://example.com/mcp`. The command creates the remote runner if needed, prepares `~/dev`, configures Git, runs selected auth flows, adds selected MCP servers to Claude Code, and then opens Claude so you can run `/mcp` and authenticate only those selected servers. With the Sprite provider, `--copy-local-codex-auth` copies `${CODEX_HOME:-$HOME/.codex}/auth.json` into `/home/sprite/.codex/auth.json` and then verifies `codex login status`; it never prints the file contents. Use `--skip-mcp-auth` when you only want to add MCP definitions, and run the `/mcp` step later.
103
+ Known MCP aliases are `linear`, `slack`, and `notion`. For another HTTP MCP server, pass `--mcp name=https://example.com/mcp`. The command creates the remote runner if needed, prepares `~/dev`, configures Git, runs selected auth flows, adds selected MCP servers to Claude Code, and then opens Claude so you can run `/mcp` and authenticate only those selected servers. With the Sprite provider, `--copy-local-codex-auth` copies `${CODEX_HOME:-$HOME/.codex}/auth.json` into `/home/sprite/.codex/auth.json` and then verifies `codex login status`; it never prints the file contents. `--datadog` installs a pinned `pup` release in the remote runner, verifies its checksum, installs the `dd-pup` guidance for Claude and Codex, verifies `pup auth status`, and when auth is missing temporarily runs `sprite proxy` for the OAuth callback while `pup auth login --read-only` runs in the remote runner. Open the Datadog URL printed by `pup` if your terminal does not open it automatically. Use `--skip-mcp-auth` when you only want to add MCP definitions, and run the `/mcp` step later.
93
104
 
94
105
  Repo setup is separate from runner setup and should run after the ticket branch exists, immediately before launching an agent. It clones/fetches the repo in the remote runner, checks out the requested branch (creating it from the base branch when it does not exist on origin), forwards only build-time secrets for the dependency install, removes the temporary secret file, clears those env vars, and then exits. It uses groundcrew's remote setup command.
95
106
 
@@ -180,13 +191,14 @@ Rules:
180
191
  ## Manual commands
181
192
 
182
193
  ```bash
183
- crew remote setup crew-claude-1 --claude --codex --copy-local-codex-auth --github --mcp linear --checkpoint
194
+ crew remote setup crew-claude-1 --claude --codex --copy-local-codex-auth --datadog --github --mcp linear --checkpoint
184
195
  crew remote bootstrap crew-claude-1 core-utils --branch rocky-team-123
185
196
  crew remote sessions
186
197
  crew remote attach <session-id-or-command> --runner crew-claude-1
187
198
  crew remote ps crew-claude-1
188
199
  crew remote interrupt <process-group-id> --runner crew-claude-1
189
200
  crew run --ticket <TICKET>
201
+ crew setup repos [--dry-run] [<repo>...]
190
202
  crew cleanup <TICKET>
191
203
  ```
192
204
 
@@ -211,6 +223,7 @@ crew cleanup <TICKET>
211
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.
212
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.
213
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.
214
227
 
215
228
  ## Hacking on groundcrew
216
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,9 +72,14 @@ 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
- usage: "setup <runner-name> [--claude] [--github] [--mcp <alias|name=url>] [--checkpoint]\n" +
82
+ usage: "setup <runner-name> [--claude] [--codex] [--datadog] [--github] [--mcp <alias|name=url>] [--checkpoint]\n" +
66
83
  " → crew remote bootstrap <runner-name> <repo> [--branch <branch>]\n" +
67
84
  " → crew remote sessions [<runner-name>]\n" +
68
85
  " → crew remote attach <session-id-or-command> [--runner <runner-name>]\n" +
@@ -8,6 +8,7 @@ export interface RemoteSetupOptions {
8
8
  shouldAuthenticateClaude: boolean;
9
9
  shouldAuthenticateCodex: boolean;
10
10
  shouldCopyLocalCodexAuth?: boolean;
11
+ shouldSetupDatadog?: boolean;
11
12
  shouldAuthenticateGithub: boolean;
12
13
  shouldAuthenticateMcp: boolean;
13
14
  shouldCheckpoint: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"remoteSetup.d.ts","sourceRoot":"","sources":["../../src/commands/remoteSetup.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,wBAAwB,EAAE,OAAO,CAAC;IAClC,uBAAuB,EAAE,OAAO,CAAC;IACjC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC,wBAAwB,EAAE,OAAO,CAAC;IAClC,qBAAqB,EAAE,OAAO,CAAC;IAC/B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,SAAS,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,4BAA4B,EAAE,OAAO,CAAC;IACtC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4rBD,wBAAsB,yBAAyB,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6B9F;AAUD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAItF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAGrF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAItF;AAED,wBAAsB,2BAA2B,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAGhG;AAYD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6BlF;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B7D"}
1
+ {"version":3,"file":"remoteSetup.d.ts","sourceRoot":"","sources":["../../src/commands/remoteSetup.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,wBAAwB,EAAE,OAAO,CAAC;IAClC,uBAAuB,EAAE,OAAO,CAAC;IACjC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,wBAAwB,EAAE,OAAO,CAAC;IAClC,qBAAqB,EAAE,OAAO,CAAC;IAC/B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,SAAS,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,4BAA4B,EAAE,OAAO,CAAC;IACtC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAy0BD,wBAAsB,yBAAyB,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6B9F;AAUD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAItF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAGrF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAItF;AAED,wBAAsB,2BAA2B,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAGhG;AAYD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgClF;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B7D"}
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
4
5
  import { BUILD_SECRET_NAMES, DEFAULT_REMOTE_SETUP_COMMAND, loadConfig, } from "../lib/config.js";
5
6
  import { shellSingleQuote } from "../lib/shell.js";
6
7
  import { getRemoteRunnerProvider, remoteConfigWithRunnerName, } from "../lib/spriteRemoteRunnerProvider.js";
@@ -14,6 +15,10 @@ const DEFAULT_CHECKPOINT_COMMENT = "groundcrew remote runner baseline: selected
14
15
  const CLAUDE_SUBSCRIPTION_LOGIN_FLAG = ["--claude", "ai"].join("");
15
16
  const DEFAULT_REPOSITORY_OWNER = "ClipboardHealth";
16
17
  const DEFAULT_BASE_BRANCH = "main";
18
+ const DATADOG_PUP_VERSION = "0.63.0";
19
+ const DATADOG_OAUTH_CALLBACK_PORT = 8000;
20
+ const DATADOG_AUTH_STATUS_RETRY_ATTEMPTS = 5;
21
+ const DATADOG_AUTH_STATUS_RETRY_DELAY_MS = 250;
17
22
  const REMOTE_SECRETS_FILE = "/tmp/groundcrew-build-secrets.env";
18
23
  const REMOTE_CODEX_AUTH_UPLOAD_FILE = "/tmp/groundcrew-codex-auth.json";
19
24
  const REMOTE_CODEX_AUTH_FILE = "/home/sprite/.codex/auth.json";
@@ -31,6 +36,7 @@ function usage() {
31
36
  " --claude Authenticate Claude Code with a Claude subscription",
32
37
  " --codex Authenticate Codex CLI",
33
38
  " --copy-local-codex-auth With --codex, copy local CODEX_HOME auth.json into the remote runner",
39
+ " --datadog Install pup, add dd-pup skills, and authenticate Datadog",
34
40
  " --github Authenticate gh for GitHub PRs",
35
41
  " --mcp <alias|name=url> Add/authenticate one MCP server; repeat for multiple",
36
42
  " Known aliases: linear, slack, notion",
@@ -134,6 +140,7 @@ function parseArguments(argv) {
134
140
  let shouldAuthenticateClaude = false;
135
141
  let shouldAuthenticateCodex = false;
136
142
  let shouldCopyLocalCodexAuth = false;
143
+ let shouldSetupDatadog = false;
137
144
  let shouldAuthenticateGithub = false;
138
145
  let shouldAuthenticateMcp = true;
139
146
  let shouldCheckpoint = false;
@@ -156,6 +163,10 @@ function parseArguments(argv) {
156
163
  shouldCopyLocalCodexAuth = true;
157
164
  continue;
158
165
  }
166
+ if (argument === "--datadog") {
167
+ shouldSetupDatadog = true;
168
+ continue;
169
+ }
159
170
  if (argument === "--github") {
160
171
  shouldAuthenticateGithub = true;
161
172
  continue;
@@ -202,6 +213,7 @@ function parseArguments(argv) {
202
213
  shouldAuthenticateClaude,
203
214
  shouldAuthenticateCodex,
204
215
  shouldCopyLocalCodexAuth,
216
+ shouldSetupDatadog,
205
217
  shouldAuthenticateGithub,
206
218
  shouldAuthenticateMcp,
207
219
  shouldCheckpoint,
@@ -457,6 +469,105 @@ async function authenticateCodex(arguments_) {
457
469
  }
458
470
  throw new Error("Codex login finished, but `codex login status` still reports not logged in inside the remote runner. Try `crew remote setup <runner-name> --codex --copy-local-codex-auth`.");
459
471
  }
472
+ function datadogPupInstallCommand() {
473
+ const shellVariable = "$";
474
+ return [
475
+ "set -euo pipefail",
476
+ `version=${shellSingleQuote(DATADOG_PUP_VERSION)}`,
477
+ 'case "$(uname -m)" in',
478
+ " x86_64|amd64) arch=x86_64 ;;",
479
+ " aarch64|arm64) arch=arm64 ;;",
480
+ ' *) echo "Unsupported Linux architecture: $(uname -m)" >&2; exit 1 ;;',
481
+ "esac",
482
+ `asset="pup_${shellVariable}{version}_Linux_${shellVariable}{arch}.tar.gz"`,
483
+ `base_url="https://github.com/DataDog/pup/releases/download/v${shellVariable}{version}"`,
484
+ 'binary="$HOME/.local/bin/pup"',
485
+ 'mkdir -p "$HOME/.local/bin"',
486
+ `if [ -x "$binary" ] && "$binary" version | grep -q "Pup ${shellVariable}{version} "; then "$binary" version; exit 0; fi`,
487
+ 'install_dir="$(mktemp -d)"',
488
+ "trap 'rm -rf \"$install_dir\"' EXIT",
489
+ `archive="${shellVariable}{install_dir}/${shellVariable}{asset}"`,
490
+ `checksums="${shellVariable}{install_dir}/pup_${shellVariable}{version}_checksums.txt"`,
491
+ 'curl -fsSL -o "$archive" "$base_url/$asset"',
492
+ `curl -fsSL -o "$checksums" "$base_url/pup_${shellVariable}{version}_checksums.txt"`,
493
+ `(cd "$install_dir" && grep " ${shellVariable}{asset}$" "$checksums" | sha256sum -c -)`,
494
+ 'tar -xzf "$archive" -C "$HOME/.local/bin" pup',
495
+ 'chmod 755 "$binary"',
496
+ '"$binary" version',
497
+ ].join("\n");
498
+ }
499
+ async function installDatadogPup(provider, config) {
500
+ log(`Installing Datadog pup ${DATADOG_PUP_VERSION} inside the remote runner`);
501
+ await provider.runCommand({
502
+ config,
503
+ remoteArguments: ["bash", "-lc", datadogPupInstallCommand()],
504
+ });
505
+ }
506
+ async function installDatadogPupSkills(provider, config) {
507
+ for (const platform of ["claude-code", "codex"]) {
508
+ // oxlint-disable-next-line no-await-in-loop -- installs are quick and ordered so logs stay readable.
509
+ await provider.runCommand({
510
+ config,
511
+ remoteArguments: ["pup", "skills", "install", platform, "--name", "dd-pup", "--yes"],
512
+ });
513
+ }
514
+ }
515
+ async function validateDatadogLogin(provider, config) {
516
+ return await commandSucceeds({
517
+ provider,
518
+ config,
519
+ remoteArguments: ["pup", "auth", "status"],
520
+ });
521
+ }
522
+ async function waitForDatadogLogin(provider, config) {
523
+ for (let attempt = 1; attempt <= DATADOG_AUTH_STATUS_RETRY_ATTEMPTS; attempt += 1) {
524
+ // oxlint-disable-next-line no-await-in-loop -- bounded polling smooths over Datadog OAuth propagation delay.
525
+ if (await validateDatadogLogin(provider, config)) {
526
+ return true;
527
+ }
528
+ if (attempt < DATADOG_AUTH_STATUS_RETRY_ATTEMPTS) {
529
+ // oxlint-disable-next-line no-await-in-loop -- retries are intentionally spaced.
530
+ await sleep(DATADOG_AUTH_STATUS_RETRY_DELAY_MS);
531
+ }
532
+ }
533
+ return false;
534
+ }
535
+ async function authenticateDatadog(provider, config) {
536
+ if (await validateDatadogLogin(provider, config)) {
537
+ return;
538
+ }
539
+ writeOutput();
540
+ writeOutput("Datadog OAuth will run inside the remote runner.");
541
+ writeOutput(`Groundcrew is forwarding local port ${DATADOG_OAUTH_CALLBACK_PORT} to the remote callback server while login runs.`);
542
+ writeOutput("Open the Datadog URL printed by pup if your terminal does not open it for you.");
543
+ writeOutput();
544
+ const proxy = await provider.startPortProxy(config, DATADOG_OAUTH_CALLBACK_PORT);
545
+ try {
546
+ await provider.runTtyCommand({
547
+ config,
548
+ remoteArguments: [
549
+ "pup",
550
+ "auth",
551
+ "login",
552
+ "--read-only",
553
+ "--callback-port",
554
+ String(DATADOG_OAUTH_CALLBACK_PORT),
555
+ ],
556
+ });
557
+ }
558
+ finally {
559
+ await proxy.close();
560
+ }
561
+ if (await waitForDatadogLogin(provider, config)) {
562
+ return;
563
+ }
564
+ throw new Error("Datadog auth finished, but `pup auth status` still reports not logged in inside the remote runner.");
565
+ }
566
+ async function setupDatadog(provider, config) {
567
+ await installDatadogPup(provider, config);
568
+ await installDatadogPupSkills(provider, config);
569
+ await authenticateDatadog(provider, config);
570
+ }
460
571
  async function addMcpServer(arguments_) {
461
572
  const { provider, config, server } = arguments_;
462
573
  const exists = await commandSucceeds({
@@ -661,6 +772,9 @@ export async function setupRemoteRunner(options) {
661
772
  shouldCopyLocalAuth: options.shouldCopyLocalCodexAuth === true,
662
773
  });
663
774
  }
775
+ if (options.shouldSetupDatadog === true) {
776
+ await setupDatadog(provider, config);
777
+ }
664
778
  for (const server of options.mcpServers) {
665
779
  // oxlint-disable-next-line no-await-in-loop -- MCP additions are sequential so auth instructions stay ordered.
666
780
  await addMcpServer({ provider, config, server });
@@ -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
+ }
@@ -54,6 +54,9 @@ export interface RemoteRunnerProvider {
54
54
  runCommand(arguments_: RemoteRunArguments): Promise<string | undefined>;
55
55
  runTtyCommand(arguments_: RemoteRunArguments): Promise<void>;
56
56
  buildTtyCommand(arguments_: RemoteTtyCommandArguments): string;
57
+ startPortProxy(config: RemoteRunnerConfig, port: number): Promise<{
58
+ close(): Promise<void>;
59
+ }>;
57
60
  listSessions(config: RemoteRunnerConfig): Promise<string>;
58
61
  attachSession(config: RemoteRunnerConfig, target: string): Promise<void>;
59
62
  listProcesses(config: RemoteRunnerConfig): Promise<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"spriteRemoteRunnerProvider.d.ts","sourceRoot":"","sources":["../../src/lib/spriteRemoteRunnerProvider.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"spriteRemoteRunnerProvider.d.ts","sourceRoot":"","sources":["../../src/lib/spriteRemoteRunnerProvider.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,12 @@
1
+ import { connect } from "node:net";
2
+ import { setTimeout as sleep } from "node:timers/promises";
1
3
  import { runCommandAsync } from "./commandRunner.js";
2
4
  import { shellSingleQuote } from "./shell.js";
3
5
  const LONG_RUNNING_COMMAND_OPTIONS = { stdio: "inherit", timeoutMs: 0 };
6
+ const PROXY_CLOSE_SIGNAL = "SIGINT";
7
+ const PROXY_READY_HOST = "127.0.0.1";
8
+ const PROXY_READY_TIMEOUT_MS = 5000;
9
+ const PROXY_READY_RETRY_DELAY_MS = 25;
4
10
  export const SPRITE_REMOTE_PROVIDER_DEFAULTS = {
5
11
  provider: "sprite",
6
12
  runnerName: "crew-claude-1",
@@ -155,6 +161,96 @@ async function createSpriteRunner(config) {
155
161
  timeoutMs: 0,
156
162
  });
157
163
  }
164
+ async function startSpritePortProxy(config, port) {
165
+ const controller = new AbortController();
166
+ let closeWasRequested = false;
167
+ let proxyError;
168
+ const proxy = (async () => {
169
+ try {
170
+ await runCommandAsync("sprite", ["proxy", "-s", config.runnerName, String(port)], {
171
+ signal: controller.signal,
172
+ stdio: "inherit",
173
+ timeoutMs: 0,
174
+ });
175
+ if (!closeWasRequested) {
176
+ proxyError = new Error("Sprite proxy exited before it was closed.");
177
+ }
178
+ }
179
+ catch (error) {
180
+ if (closeWasRequested && errorHasSignal(error, PROXY_CLOSE_SIGNAL)) {
181
+ return;
182
+ }
183
+ proxyError = new Error(`Sprite proxy exited before it was closed: ${String(error)}`, {
184
+ cause: error,
185
+ });
186
+ }
187
+ })();
188
+ try {
189
+ await waitForSpritePortProxy({ port, proxy, proxyError: () => proxyError });
190
+ }
191
+ catch (error) {
192
+ closeWasRequested = true;
193
+ controller.abort();
194
+ await proxy;
195
+ throw error;
196
+ }
197
+ return {
198
+ async close() {
199
+ closeWasRequested = true;
200
+ controller.abort();
201
+ await proxy;
202
+ if (proxyError !== undefined) {
203
+ throw new Error(proxyError.message, { cause: proxyError });
204
+ }
205
+ },
206
+ };
207
+ }
208
+ async function waitForSpritePortProxy(arguments_) {
209
+ const deadline = Date.now() + PROXY_READY_TIMEOUT_MS;
210
+ while (Date.now() < deadline) {
211
+ throwIfProxyExited(arguments_.proxyError());
212
+ // eslint-disable-next-line no-await-in-loop -- readiness polling must observe attempts sequentially.
213
+ if (await canConnectToLocalPort(arguments_.port)) {
214
+ throwIfProxyExited(arguments_.proxyError());
215
+ return;
216
+ }
217
+ throwIfProxyExited(arguments_.proxyError());
218
+ // eslint-disable-next-line no-await-in-loop -- retry delay is bounded and stops early if the proxy exits.
219
+ await Promise.race([sleep(PROXY_READY_RETRY_DELAY_MS), arguments_.proxy]);
220
+ }
221
+ throwIfProxyExited(arguments_.proxyError());
222
+ throw new Error(`Timed out waiting for Sprite proxy on ${PROXY_READY_HOST}:${arguments_.port} to accept connections.`);
223
+ }
224
+ function throwIfProxyExited(error) {
225
+ if (error !== undefined) {
226
+ throw new Error(error.message, { cause: error });
227
+ }
228
+ }
229
+ async function canConnectToLocalPort(port) {
230
+ return await new Promise((resolve) => {
231
+ const socket = connect({ host: PROXY_READY_HOST, port });
232
+ socket.once("connect", () => {
233
+ socket.destroy();
234
+ resolve(true);
235
+ });
236
+ socket.once("error", () => {
237
+ socket.destroy();
238
+ resolve(false);
239
+ });
240
+ });
241
+ }
242
+ function errorHasSignal(error, signal) {
243
+ if (typeof error !== "object" || error === null) {
244
+ return false;
245
+ }
246
+ if ("signal" in error && error.signal === signal) {
247
+ return true;
248
+ }
249
+ if (error instanceof Error && error.cause !== undefined) {
250
+ return errorHasSignal(error.cause, signal);
251
+ }
252
+ return false;
253
+ }
158
254
  export const spriteRemoteRunnerProvider = {
159
255
  name: "sprite",
160
256
  async runnerExists(config) {
@@ -174,6 +270,9 @@ export const spriteRemoteRunnerProvider = {
174
270
  });
175
271
  },
176
272
  buildTtyCommand: buildSpriteTtyCommand,
273
+ async startPortProxy(config, port) {
274
+ return await startSpritePortProxy(config, port);
275
+ },
177
276
  async listSessions(config) {
178
277
  const output = await runCommandAsync("sprite", ["sessions", "list", "-s", config.runnerName], {
179
278
  trim: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "1.10.2",
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",