@clipboard-health/groundcrew 3.1.9 → 3.2.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.
Files changed (42) hide show
  1. package/README.md +10 -6
  2. package/crew.config.example.ts +19 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +12 -0
  5. package/dist/commands/dispatcher.d.ts.map +1 -1
  6. package/dist/commands/dispatcher.js +10 -10
  7. package/dist/commands/init.d.ts +26 -0
  8. package/dist/commands/init.d.ts.map +1 -0
  9. package/dist/commands/init.js +82 -0
  10. package/dist/commands/sandbox/auth.d.ts +3 -0
  11. package/dist/commands/sandbox/auth.d.ts.map +1 -0
  12. package/dist/commands/sandbox/auth.js +227 -0
  13. package/dist/commands/sandbox/index.d.ts +2 -0
  14. package/dist/commands/sandbox/index.d.ts.map +1 -0
  15. package/dist/commands/sandbox/index.js +47 -0
  16. package/dist/commands/sandbox/inspect.d.ts +2 -0
  17. package/dist/commands/sandbox/inspect.d.ts.map +1 -0
  18. package/dist/commands/sandbox/inspect.js +18 -0
  19. package/dist/commands/sandbox/lifecycle.d.ts +7 -0
  20. package/dist/commands/sandbox/lifecycle.d.ts.map +1 -0
  21. package/dist/commands/sandbox/lifecycle.js +68 -0
  22. package/dist/commands/sandbox/model.d.ts +10 -0
  23. package/dist/commands/sandbox/model.d.ts.map +1 -0
  24. package/dist/commands/sandbox/model.js +37 -0
  25. package/dist/commands/sandbox/picker.d.ts +20 -0
  26. package/dist/commands/sandbox/picker.d.ts.map +1 -0
  27. package/dist/commands/sandbox/picker.js +23 -0
  28. package/dist/lib/agentLaunch.d.ts.map +1 -1
  29. package/dist/lib/agentLaunch.js +1 -0
  30. package/dist/lib/config.d.ts +70 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/config.js +79 -13
  33. package/dist/lib/dockerSandbox.d.ts +12 -8
  34. package/dist/lib/dockerSandbox.d.ts.map +1 -1
  35. package/dist/lib/dockerSandbox.js +33 -22
  36. package/dist/lib/sandboxGitDefaults.d.ts +10 -0
  37. package/dist/lib/sandboxGitDefaults.d.ts.map +1 -0
  38. package/dist/lib/sandboxGitDefaults.js +31 -0
  39. package/dist/lib/xdg.d.ts +3 -0
  40. package/dist/lib/xdg.d.ts.map +1 -0
  41. package/dist/lib/xdg.js +19 -0
  42. package/package.json +12 -11
package/README.md CHANGED
@@ -60,16 +60,19 @@ Installs the `crew` binary. `@clipboard-health/clearance` is pulled in transitiv
60
60
 
61
61
  3. **Create a Linear project to scope your work.** Any team works — make a project inside it and drop tickets in. The orchestrator polls by project, not by team.
62
62
 
63
- 4. **Configure.** Copy the shipped example into XDG config and edit:
63
+ 4. **Configure.** Create a `crew.config.ts` you can edit:
64
64
 
65
65
  ```bash
66
- mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew"
67
- cp "$(npm root -g)/@clipboard-health/groundcrew/crew.config.example.ts" \
68
- "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts"
69
- $EDITOR "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts"
66
+ # Write into the current folder:
67
+ crew init && $EDITOR crew.config.ts
68
+
69
+ # ...or into the XDG config dir:
70
+ crew init --global && $EDITOR "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts"
70
71
  ```
71
72
 
72
- Or drop `crew.config.ts` at the root of any repo you run `crew` from `crew` discovers it via cosmiconfig project-walk. Any of `crew.config.{ts,mjs,js,json}`, `.crewrc{,.json,.ts}`, `.config/crew.config.{ts,json}`, or `.config/crewrc{,.json}` work.
73
+ `crew init` refuses to overwrite an existing config; pass `--force` to replace it, or `--dry-run` to preview the destination path.
74
+
75
+ `crew` discovers the config via cosmiconfig project-walk, so dropping it at the root of any repo you run `crew` from works too. Any of `crew.config.{ts,mjs,js,json}`, `.crewrc{,.json,.ts}`, `.config/crew.config.{ts,json}`, or `.config/crewrc{,.json}` are recognized.
73
76
 
74
77
  Set `linear.projects[].projectSlug` (paste the trailing slug of your Linear project URL, e.g. `ai-strategy-5152195762f3`), `workspace.projectDir`, and `workspace.knownRepositories`. Defaults cover everything else. To watch multiple projects from one `crew` instance, add more entries to `linear.projects`; they all share the same `orchestrator.maximumInProgress` budget.
75
78
 
@@ -359,6 +362,7 @@ To have a coding agent (Claude Code, Cursor, etc.) scaffold `.groundcrew/setup.s
359
362
  ## Commands
360
363
 
361
364
  ```bash
365
+ crew init [--global | --local] [--force] [--dry-run] # create a crew.config.ts (cwd by default)
362
366
  crew doctor # full setup check
363
367
  crew doctor --ticket <TICKET> [--no-linear] [--no-fetch] # full ticket lifecycle (dispatch + recovery)
364
368
  crew run # one-shot dispatch
@@ -92,6 +92,25 @@ export default {
92
92
  // // macOS when you need an agent to use Docker safely.
93
93
  // local: { runner: "auto" },
94
94
  //
95
+ // // Additional auth recipes for `crew sandbox auth <model> <tool>`. The
96
+ // // shipped recipes (claude/codex/cursor agents + github tool) are merged
97
+ // // with whatever you declare here; your recipe wins on key collision.
98
+ // // Describe each tool's in-sandbox login + status commands and a regex
99
+ // // that matches its logged-in output. Omit `kind` for cross-cutting
100
+ // // tools that should appear in every sandbox's picker; set
101
+ // // `kind: "agent"` to scope a recipe to a single sbx agent.
102
+ // sandbox: {
103
+ // authRecipes: {
104
+ // gcloud: {
105
+ // displayName: "gcloud",
106
+ // binary: "gcloud",
107
+ // loginArgs: ["auth", "login", "--no-launch-browser"],
108
+ // statusArgs: ["auth", "list", "--filter=status:ACTIVE", "--format=value(account)"],
109
+ // authenticatedPattern: /@/,
110
+ // },
111
+ // },
112
+ // },
113
+ //
95
114
  // prompts: {
96
115
  // // Keep personal workflow instructions next to this config, for example
97
116
  // // `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/initial-prompt.md`.
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AA6JA,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":"AAyKA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
package/dist/cli.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { cleanupWorkspaceCli } from "./commands/cleanupWorkspace.js";
3
3
  import { doctor } from "./commands/doctor.js";
4
+ import { initConfigCli } from "./commands/init.js";
4
5
  import { interruptWorkspaceCli } from "./commands/interruptWorkspace.js";
5
6
  import { orchestrate } from "./commands/orchestrator.js";
6
7
  import { resumeWorkspaceCli } from "./commands/resumeWorkspace.js";
8
+ import { sandboxCli } from "./commands/sandbox/index.js";
7
9
  import { setupReposCli } from "./commands/setupRepos.js";
8
10
  import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
9
11
  import { errorMessage, readTicketArgument, writeError, writeOutput } from "./lib/util.js";
@@ -77,6 +79,11 @@ async function doctorCli(argv) {
77
79
  process.exitCode = ok ? process.exitCode : 1;
78
80
  }
79
81
  const SUBCOMMANDS = {
82
+ init: {
83
+ summary: "Create a crew.config.ts in the cwd (or --global into the XDG config dir)",
84
+ usage: "[--global | --local] [--force] [--dry-run]",
85
+ invoke: initConfigCli,
86
+ },
80
87
  run: {
81
88
  summary: "Run the orchestrator (one-shot by default), or provision one ticket with --ticket",
82
89
  usage: "[--watch] [--dry-run] [--ticket <ticket>]",
@@ -102,6 +109,11 @@ const SUBCOMMANDS = {
102
109
  usage: "<ticket>",
103
110
  invoke: resumeWorkspaceCli,
104
111
  },
112
+ sandbox: {
113
+ summary: "Manage Docker Sandboxes (sbx) for configured models",
114
+ usage: "<list|ensure|regenerate|auth|rm> [...args]",
115
+ invoke: sandboxCli,
116
+ },
105
117
  setup: {
106
118
  summary: "Project-level setup commands (currently: repos)",
107
119
  usage: "repos [--dry-run] [<repo>...]",
@@ -1 +1 @@
1
- {"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAWzD,UAAU,cAAc;IACtB,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,+FAA+F;QAC/F,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;QACvD,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,CAuMjE"}
1
+ {"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAWzD,UAAU,cAAc;IACtB,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,+FAA+F;QAC/F,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;QACvD,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAaD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,CA4LjE"}
@@ -12,6 +12,16 @@ import { errorMessage, log, logEvent } from "../lib/util.js";
12
12
  import { workspaces } from "../lib/workspaces.js";
13
13
  import { classifyBlockers, classifyEligibility, classifyUsageExhaustion, } from "./eligibility.js";
14
14
  import { setupWorkspace } from "./setupWorkspace.js";
15
+ function logSkip(verdict) {
16
+ log(verdict.message);
17
+ logEvent("dispatch", {
18
+ outcome: "skipped",
19
+ reason: verdict.eventReason,
20
+ ticket: verdict.issue.id,
21
+ blockers: verdict.blockers,
22
+ model: verdict.model,
23
+ });
24
+ }
15
25
  export function createDispatcher(deps) {
16
26
  const { config, client } = deps;
17
27
  const issueStatusUpdater = createLinearIssueStatusUpdater({ config, client });
@@ -23,16 +33,6 @@ export function createDispatcher(deps) {
23
33
  }
24
34
  return exhausted;
25
35
  }
26
- function logSkip(verdict) {
27
- log(verdict.message);
28
- logEvent("dispatch", {
29
- outcome: "skipped",
30
- reason: verdict.eventReason,
31
- ticket: verdict.issue.id,
32
- blockers: verdict.blockers,
33
- model: verdict.model,
34
- });
35
- }
36
36
  async function startEligibleIssue(start, dryRun, signal) {
37
37
  const { issue, recovery } = start;
38
38
  if (start.resolvedFromAny) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `crew init` — create a `crew.config.ts` in the current working directory or,
3
+ * with `--global`, in the XDG groundcrew config dir. The contents come from
4
+ * the shipped `crew.config.example.ts` so a fresh install skips the manual
5
+ * `cp` dance documented in the README.
6
+ */
7
+ type InitConfigScope = "global" | "local";
8
+ interface InitConfigOptions {
9
+ /** Where to write the config. Defaults to "local" (cwd). */
10
+ scope?: InitConfigScope;
11
+ /** Overwrite an existing destination. */
12
+ force?: boolean;
13
+ /** Report the planned action without touching the filesystem. */
14
+ dryRun?: boolean;
15
+ /** Override for the working directory; defaults to `process.cwd()`. */
16
+ cwd?: string;
17
+ }
18
+ type InitConfigOutcome = "dry-run-would-write" | "exists" | "wrote";
19
+ interface InitConfigResult {
20
+ destination: string;
21
+ outcome: InitConfigOutcome;
22
+ }
23
+ export declare function initConfig(options?: InitConfigOptions): InitConfigResult;
24
+ export declare function initConfigCli(argv: string[]): Promise<void>;
25
+ export {};
26
+ //# sourceMappingURL=init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,KAAK,eAAe,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1C,UAAU,iBAAiB;IACzB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,KAAK,iBAAiB,GAAG,qBAAqB,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpE,UAAU,gBAAgB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,iBAAiB,CAAC;CAC5B;AAED,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAoB5E;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBjE"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `crew init` — create a `crew.config.ts` in the current working directory or,
3
+ * with `--global`, in the XDG groundcrew config dir. The contents come from
4
+ * the shipped `crew.config.example.ts` so a fresh install skips the manual
5
+ * `cp` dance documented in the README.
6
+ */
7
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { log, writeOutput } from "../lib/util.js";
10
+ import { xdgConfigPath } from "../lib/xdg.js";
11
+ const CONFIG_FILE_NAME = "crew.config.ts";
12
+ const EXAMPLE_FILE_NAME = "crew.config.example.ts";
13
+ export function initConfig(options = {}) {
14
+ const scope = options.scope ?? "local";
15
+ const cwd = options.cwd ?? process.cwd();
16
+ const source = resolveExamplePath();
17
+ const destination = destinationFor({ scope, cwd });
18
+ if (existsSync(destination) && options.force !== true) {
19
+ log(`[exists] ${destination} — pass --force to overwrite`);
20
+ return { destination, outcome: "exists" };
21
+ }
22
+ if (options.dryRun === true) {
23
+ log(`[dry-run] would write ${destination}`);
24
+ return { destination, outcome: "dry-run-would-write" };
25
+ }
26
+ mkdirSync(dirname(destination), { recursive: true });
27
+ copyFileSync(source, destination);
28
+ log(`[wrote] ${destination}`);
29
+ return { destination, outcome: "wrote" };
30
+ }
31
+ export async function initConfigCli(argv) {
32
+ const options = parseArguments(argv);
33
+ const result = initConfig(options);
34
+ if (result.outcome === "exists") {
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ if (result.outcome === "wrote") {
39
+ writeOutput("");
40
+ writeOutput("Next steps:");
41
+ writeOutput(` - Edit ${result.destination}`);
42
+ writeOutput(" - Set linear.projects[].projectSlug, workspace.projectDir, workspace.knownRepositories");
43
+ writeOutput(" - Export GROUNDCREW_LINEAR_API_KEY (or LINEAR_API_KEY)");
44
+ writeOutput(" - Verify with `crew doctor`");
45
+ }
46
+ }
47
+ function parseArguments(argv) {
48
+ let scope;
49
+ let force = false;
50
+ let dryRun = false;
51
+ for (const argument of argv) {
52
+ if (argument === "--global" || argument === "--local") {
53
+ const next = argument === "--global" ? "global" : "local";
54
+ if (scope !== undefined && scope !== next) {
55
+ throw new Error("crew init: --global and --local are mutually exclusive.\nUsage: crew init [--global | --local] [--force] [--dry-run]");
56
+ }
57
+ scope = next;
58
+ continue;
59
+ }
60
+ if (argument === "--force") {
61
+ force = true;
62
+ continue;
63
+ }
64
+ if (argument === "--dry-run") {
65
+ dryRun = true;
66
+ continue;
67
+ }
68
+ throw new Error(`Unknown option: ${argument}\nUsage: crew init [--global | --local] [--force] [--dry-run]`);
69
+ }
70
+ return { scope: scope ?? "local", force, dryRun };
71
+ }
72
+ function destinationFor(args) {
73
+ if (args.scope === "global") {
74
+ return xdgConfigPath("groundcrew", CONFIG_FILE_NAME);
75
+ }
76
+ return resolve(args.cwd, CONFIG_FILE_NAME);
77
+ }
78
+ function resolveExamplePath() {
79
+ // `init.ts` lives at src/commands/init.ts in source and dist/commands/init.js
80
+ // after build; the example ships at the package root in both cases.
81
+ return resolve(import.meta.dirname, "..", "..", EXAMPLE_FILE_NAME);
82
+ }
@@ -0,0 +1,3 @@
1
+ import type { ResolvedConfig } from "../../lib/config.ts";
2
+ export declare function runAuth(config: ResolvedConfig, argv: string[]): Promise<void>;
3
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/auth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAc,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAuNtE,wBAAsB,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAcnF"}
@@ -0,0 +1,227 @@
1
+ import { runCommandAsync } from "../../lib/commandRunner.js";
2
+ import { writeOutput } from "../../lib/util.js";
3
+ import { ensureOne } from "./lifecycle.js";
4
+ import { resolveModel, sandboxModels } from "./model.js";
5
+ import { pickTools } from "./picker.js";
6
+ /**
7
+ * Built-in recipes shipped with crew. Users register additional tools
8
+ * by adding entries under `sandbox.authRecipes` in `crew.config.ts`;
9
+ * a user recipe under the same key overrides the built-in.
10
+ *
11
+ * `kind: "agent"` recipes only appear in the picker when the current
12
+ * sandbox's agent matches the recipe key. `kind: "tool"` (the default
13
+ * for user recipes) is cross-cutting and always appears.
14
+ */
15
+ const BUILTIN_AUTH_RECIPES = {
16
+ claude: {
17
+ displayName: "Claude",
18
+ loginArgs: ["auth", "login"],
19
+ statusArgs: ["auth", "status"],
20
+ authenticatedPattern: /"loggedIn"\s*:\s*true/,
21
+ kind: "agent",
22
+ },
23
+ codex: {
24
+ displayName: "Codex",
25
+ // `--device-auth` keeps the OAuth flow headless: codex prints a URL
26
+ // and a code instead of trying to open a browser inside the sandbox.
27
+ loginArgs: ["login", "--device-auth"],
28
+ statusArgs: ["login", "status"],
29
+ // Match "Logged in using …" but not a hypothetical "Not logged in".
30
+ authenticatedPattern: /(^|\W)Logged in using\b/i,
31
+ kind: "agent",
32
+ },
33
+ cursor: {
34
+ displayName: "Cursor",
35
+ binary: "cursor-agent",
36
+ loginArgs: ["login"],
37
+ statusArgs: ["status"],
38
+ // Authenticated output is "✓ Logged in as <email>"; the unauthenticated
39
+ // output is "Not logged in", which a loose /Logged in/i would falsely
40
+ // match.
41
+ authenticatedPattern: /Logged in as\b/i,
42
+ kind: "agent",
43
+ // cursor-agent tries to open a browser by default and silently
44
+ // writes a partial auth file when xdg-open fails; this env var
45
+ // switches it to a device-code flow that works without a browser.
46
+ env: { NO_OPEN_BROWSER: "1" },
47
+ },
48
+ github: {
49
+ displayName: "GitHub CLI",
50
+ binary: "gh",
51
+ loginArgs: ["auth", "login"],
52
+ statusArgs: ["auth", "status"],
53
+ authenticatedPattern: /Logged in to github\.com/i,
54
+ kind: "tool",
55
+ },
56
+ };
57
+ function binaryFor(toolKey, recipe) {
58
+ return recipe.binary ?? toolKey;
59
+ }
60
+ function envFlags(recipe) {
61
+ const entries = Object.entries(recipe.env ?? {});
62
+ return entries.flatMap(([key, value]) => ["-e", `${key}=${value}`]);
63
+ }
64
+ // User-supplied recipes can carry arbitrary tokens; wrap each in single
65
+ // quotes so spaces and shell metacharacters can't change how the in-sandbox
66
+ // shell tokenizes the status command.
67
+ function shellQuote(value) {
68
+ return `'${value.replaceAll("'", `'\\''`)}'`;
69
+ }
70
+ async function probeAuthStatus(sandboxName, toolKey, recipe) {
71
+ // Some CLIs print status to stderr instead of stdout (codex does
72
+ // this). Fold stderr into stdout via the in-sandbox shell so the
73
+ // pattern match sees the message regardless of which stream it
74
+ // landed on.
75
+ const innerCommand = `${[binaryFor(toolKey, recipe), ...recipe.statusArgs]
76
+ .map(shellQuote)
77
+ .join(" ")} 2>&1`;
78
+ try {
79
+ const output = await runCommandAsync("sbx", [
80
+ "exec",
81
+ ...envFlags(recipe),
82
+ sandboxName,
83
+ "sh",
84
+ "-c",
85
+ innerCommand,
86
+ ]);
87
+ // Reset lastIndex so a /g or /y user recipe doesn't carry state
88
+ // across probes and return a false negative.
89
+ recipe.authenticatedPattern.lastIndex = 0;
90
+ return recipe.authenticatedPattern.test(output);
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ async function loginAndVerify(input) {
97
+ const { sandboxName, toolKey, recipe, modelName, gitDefaults } = input;
98
+ const binary = binaryFor(toolKey, recipe);
99
+ writeOutput(`${sandboxName}: launching '${recipe.displayName}' login...`);
100
+ writeOutput("Complete the login flow in the prompts/browser, then return here.");
101
+ await runCommandAsync("sbx", ["exec", "-it", ...envFlags(recipe), sandboxName, binary, ...recipe.loginArgs], { stdio: "inherit" });
102
+ writeOutput("");
103
+ writeOutput(`${sandboxName}: verifying '${recipe.displayName}' authentication...`);
104
+ const authenticated = await probeAuthStatus(sandboxName, toolKey, recipe);
105
+ if (authenticated) {
106
+ writeOutput(`${sandboxName}: '${recipe.displayName}' authenticated.`);
107
+ if (gitDefaults && toolKey === "github" && binary === "gh") {
108
+ await runGhSetupGit(sandboxName);
109
+ }
110
+ return;
111
+ }
112
+ writeOutput(`${sandboxName}: could not confirm '${recipe.displayName}' authentication — re-run 'crew sandbox auth ${modelName}' to retry.`);
113
+ }
114
+ /**
115
+ * Register `gh` as git's credential helper inside the sandbox so HTTPS
116
+ * pushes succeed without prompting. Best-effort — a failure here doesn't
117
+ * undo the login itself, so we warn and move on.
118
+ */
119
+ async function runGhSetupGit(sandboxName) {
120
+ writeOutput(`${sandboxName}: wiring 'gh' as git credential helper...`);
121
+ try {
122
+ await runCommandAsync("sbx", ["exec", sandboxName, "gh", "auth", "setup-git"]);
123
+ writeOutput(`${sandboxName}: 'gh auth setup-git' done.`);
124
+ }
125
+ catch (error) {
126
+ writeOutput(`${sandboxName}: warning — 'gh auth setup-git' failed: ${String(error)}`);
127
+ }
128
+ }
129
+ function availableRecipes(config) {
130
+ const merged = {
131
+ ...BUILTIN_AUTH_RECIPES,
132
+ ...config.sandbox.authRecipes,
133
+ };
134
+ return Object.entries(merged)
135
+ .map(([key, recipe]) => ({ key, recipe }))
136
+ .toSorted((a, b) => a.key.localeCompare(b.key));
137
+ }
138
+ function shouldShowInPicker(entry, currentAgent) {
139
+ // Tools (the default) appear in every sandbox. Agent recipes only
140
+ // appear when they match the current sandbox's agent, so opening
141
+ // 'crew sandbox auth codex' doesn't list Claude or Cursor.
142
+ const kind = entry.recipe.kind ?? "tool";
143
+ return kind === "tool" || entry.key === currentAgent;
144
+ }
145
+ const AUTH_USAGE = "Usage: crew sandbox auth <model> | --all";
146
+ function parseAuthArgs(config, argv) {
147
+ const positionals = [];
148
+ let all = false;
149
+ for (const argument of argv) {
150
+ if (argument === "--all") {
151
+ all = true;
152
+ continue;
153
+ }
154
+ if (argument.startsWith("-")) {
155
+ throw new Error(`crew sandbox auth: unknown option '${argument}'`);
156
+ }
157
+ positionals.push(argument);
158
+ }
159
+ if (all && positionals.length > 0) {
160
+ throw new Error("crew sandbox auth: --all cannot be combined with a model name");
161
+ }
162
+ if (all) {
163
+ const models = sandboxModels(config);
164
+ if (models.length === 0) {
165
+ throw new Error("crew sandbox auth --all: no sandbox-bearing models configured");
166
+ }
167
+ return { models: models.map((model) => ({ modelName: model.modelName, model })) };
168
+ }
169
+ const [modelName, ...extras] = positionals;
170
+ if (modelName === undefined || extras.length > 0) {
171
+ throw new Error(AUTH_USAGE);
172
+ }
173
+ return { models: [{ modelName, model: resolveModel(config, modelName) }] };
174
+ }
175
+ export async function runAuth(config, argv) {
176
+ const { models } = parseAuthArgs(config, argv);
177
+ for (const [index, { modelName, model }] of models.entries()) {
178
+ if (models.length > 1) {
179
+ writeOutput("");
180
+ writeOutput(`════ ${modelName} (${index + 1}/${models.length}) ════`);
181
+ }
182
+ writeOutput(`${model.sandboxName}: ensuring sandbox is up...`);
183
+ // oxlint-disable-next-line no-await-in-loop -- each sandbox is interactive; running them sequentially keeps the prompts coherent
184
+ await ensureOne(config, model);
185
+ writeOutput("");
186
+ // oxlint-disable-next-line no-await-in-loop -- intentionally sequential, see above
187
+ await runAuthInteractive(config, model, modelName);
188
+ }
189
+ }
190
+ async function runAuthInteractive(config, model, modelName) {
191
+ const recipes = availableRecipes(config).filter((entry) => shouldShowInPicker(entry, model.sandbox.agent));
192
+ writeOutput(`${model.sandboxName}: probing authentication status for ${recipes.length} tools...`);
193
+ const statuses = await Promise.all(recipes.map(async ({ key, recipe }) => ({
194
+ key,
195
+ recipe,
196
+ authenticated: await probeAuthStatus(model.sandboxName, key, recipe),
197
+ })));
198
+ const choices = statuses.map(({ key, recipe, authenticated }) => ({
199
+ key,
200
+ label: `${recipe.displayName} (${key})`,
201
+ authenticated,
202
+ }));
203
+ writeOutput("");
204
+ const selectedKeys = await pickTools(choices);
205
+ if (selectedKeys.length === 0) {
206
+ writeOutput("Nothing selected. Exiting.");
207
+ return;
208
+ }
209
+ const selectedRecipes = new Map(statuses.map((entry) => [entry.key, entry.recipe]));
210
+ for (const key of selectedKeys) {
211
+ const recipe = selectedRecipes.get(key);
212
+ /* v8 ignore next 3 @preserve - defensive; selectedKeys come from the same map */
213
+ if (recipe === undefined) {
214
+ continue;
215
+ }
216
+ writeOutput("");
217
+ writeOutput(`── ${recipe.displayName} ──`);
218
+ // oxlint-disable-next-line no-await-in-loop -- each login is interactive; running them sequentially keeps the prompts coherent
219
+ await loginAndVerify({
220
+ sandboxName: model.sandboxName,
221
+ toolKey: key,
222
+ recipe,
223
+ modelName,
224
+ gitDefaults: config.sandbox.gitDefaults,
225
+ });
226
+ }
227
+ }
@@ -0,0 +1,2 @@
1
+ export declare function sandboxCli(argv: string[]): Promise<void>;
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/index.ts"],"names":[],"mappings":"AAkBA,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8B9D"}
@@ -0,0 +1,47 @@
1
+ import { loadConfig } from "../../lib/config.js";
2
+ import { runAuth } from "./auth.js";
3
+ import { runList } from "./inspect.js";
4
+ import { runEnsure, runRegenerate, runRemove } from "./lifecycle.js";
5
+ const USAGE = [
6
+ "Usage: crew sandbox <verb> [...args]",
7
+ "",
8
+ "Verbs:",
9
+ " list Show every groundcrew-owned sandbox known to sbx",
10
+ " ensure [<model>] Provision the sandbox for one model, or all when omitted",
11
+ " regenerate <model>|--all Tear down and recreate from current template/kits",
12
+ " auth <model>|--all Open a checkbox picker of every tool available in <model>'s",
13
+ " sandbox and run the login flow for each one you select;",
14
+ " --all loops through every configured sandbox in turn",
15
+ " rm <model> Remove the sandbox for a model",
16
+ ].join("\n");
17
+ export async function sandboxCli(argv) {
18
+ const [verb, ...rest] = argv;
19
+ if (verb === undefined) {
20
+ throw new Error(USAGE);
21
+ }
22
+ switch (verb) {
23
+ case "list": {
24
+ await runList();
25
+ return;
26
+ }
27
+ case "ensure": {
28
+ await runEnsure(await loadConfig(), rest);
29
+ return;
30
+ }
31
+ case "regenerate": {
32
+ await runRegenerate(await loadConfig(), rest);
33
+ return;
34
+ }
35
+ case "auth": {
36
+ await runAuth(await loadConfig(), rest);
37
+ return;
38
+ }
39
+ case "rm": {
40
+ await runRemove(await loadConfig(), rest);
41
+ return;
42
+ }
43
+ default: {
44
+ throw new Error(`Unknown sandbox sub-verb: ${verb}\n${USAGE}`);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,2 @@
1
+ export declare function runList(): Promise<void>;
2
+ //# sourceMappingURL=inspect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/inspect.ts"],"names":[],"mappings":"AAKA,wBAAsB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAc7C"}
@@ -0,0 +1,18 @@
1
+ import { runCommandAsync } from "../../lib/commandRunner.js";
2
+ import { writeOutput } from "../../lib/util.js";
3
+ const SANDBOX_NAME_PREFIX = "groundcrew-";
4
+ export async function runList() {
5
+ const output = await runCommandAsync("sbx", ["ls"]);
6
+ const names = output
7
+ .split("\n")
8
+ .map((line) => line.trim().split(/\s+/)[0])
9
+ .filter((name) => name !== undefined && name.startsWith(SANDBOX_NAME_PREFIX))
10
+ .map((name) => name.slice(SANDBOX_NAME_PREFIX.length));
11
+ if (names.length === 0) {
12
+ writeOutput("(none)");
13
+ return;
14
+ }
15
+ for (const name of names) {
16
+ writeOutput(name);
17
+ }
18
+ }
@@ -0,0 +1,7 @@
1
+ import type { ResolvedConfig } from "../../lib/config.ts";
2
+ import { type SandboxModel } from "./model.ts";
3
+ export declare function ensureOne(config: ResolvedConfig, model: SandboxModel, alreadyExists?: boolean): Promise<void>;
4
+ export declare function runEnsure(config: ResolvedConfig, argv: string[]): Promise<void>;
5
+ export declare function runRegenerate(config: ResolvedConfig, argv: string[]): Promise<void>;
6
+ export declare function runRemove(config: ResolvedConfig, argv: string[]): Promise<void>;
7
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/lifecycle.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,EAAsC,KAAK,YAAY,EAAiB,MAAM,YAAY,CAAC;AAElG,wBAAsB,SAAS,CAC7B,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,EACnB,aAAa,CAAC,EAAE,OAAO,GACtB,OAAO,CAAC,IAAI,CAAC,CAQf;AAMD,wBAAsB,SAAS,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBrF;AAUD,wBAAsB,aAAa,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBzF;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAMrF"}
@@ -0,0 +1,68 @@
1
+ import { resolve } from "node:path";
2
+ import { runCommandAsync } from "../../lib/commandRunner.js";
3
+ import { ensureSandbox, sandboxExists } from "../../lib/dockerSandbox.js";
4
+ import { writeOutput } from "../../lib/util.js";
5
+ import { requireOnePositional, resolveModel, sandboxModels } from "./model.js";
6
+ export async function ensureOne(config, model, alreadyExists) {
7
+ await ensureSandbox({
8
+ sandboxName: model.sandboxName,
9
+ sandbox: model.sandbox,
10
+ mountPath: resolve(config.workspace.projectDir),
11
+ gitDefaults: config.sandbox.gitDefaults,
12
+ ...(alreadyExists === undefined ? {} : { alreadyExists }),
13
+ });
14
+ }
15
+ async function removeOne(model) {
16
+ await runCommandAsync("sbx", ["rm", "--force", model.sandboxName]);
17
+ }
18
+ export async function runEnsure(config, argv) {
19
+ const targets = argv.length === 0
20
+ ? sandboxModels(config)
21
+ : [resolveModel(config, requireOnePositional(argv, "Usage: crew sandbox ensure [<model>]"))];
22
+ if (targets.length === 0) {
23
+ writeOutput("No sandbox models configured.");
24
+ return;
25
+ }
26
+ for (const model of targets) {
27
+ // oxlint-disable-next-line no-await-in-loop -- one sandbox at a time; probe then ensure
28
+ const existed = await sandboxExists(model.sandboxName);
29
+ writeOutput(existed
30
+ ? `${model.sandboxName}: already exists`
31
+ : `${model.sandboxName}: creating (agent=${model.sandbox.agent}, template=${model.sandbox.template ?? "default"})`);
32
+ // oxlint-disable-next-line no-await-in-loop -- sbx create is intentionally sequential
33
+ await ensureOne(config, model, existed);
34
+ if (!existed) {
35
+ writeOutput(`${model.sandboxName}: created`);
36
+ }
37
+ }
38
+ }
39
+ function regenerateTargets(config, argv) {
40
+ const target = requireOnePositional(argv, "Usage: crew sandbox regenerate <model>|--all");
41
+ if (target === "--all") {
42
+ return sandboxModels(config);
43
+ }
44
+ return [resolveModel(config, target)];
45
+ }
46
+ export async function runRegenerate(config, argv) {
47
+ const targets = regenerateTargets(config, argv);
48
+ if (targets.length === 0) {
49
+ writeOutput("No sandbox models configured.");
50
+ return;
51
+ }
52
+ for (const model of targets) {
53
+ writeOutput(`${model.sandboxName}: removing existing sandbox...`);
54
+ // oxlint-disable-next-line no-await-in-loop -- sbx rm/create are intentionally sequential
55
+ await removeOne(model);
56
+ writeOutput(`${model.sandboxName}: creating (agent=${model.sandbox.agent}, template=${model.sandbox.template ?? "default"})`);
57
+ // oxlint-disable-next-line no-await-in-loop -- sbx rm/create are intentionally sequential
58
+ await ensureOne(config, model, false);
59
+ writeOutput(`${model.sandboxName}: regenerated`);
60
+ }
61
+ }
62
+ export async function runRemove(config, argv) {
63
+ const modelName = requireOnePositional(argv, "Usage: crew sandbox rm <model>");
64
+ const model = resolveModel(config, modelName);
65
+ writeOutput(`${model.sandboxName}: removing...`);
66
+ await removeOne(model);
67
+ writeOutput(`${model.sandboxName}: removed`);
68
+ }