borgmcp 3.9.0 → 3.11.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.
Files changed (85) hide show
  1. package/SECURITY.md +1 -1
  2. package/dist/assimilate-cmd.d.ts +14 -1
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +19 -12
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/claude.d.ts.map +1 -1
  7. package/dist/claude.js +25 -3
  8. package/dist/claude.js.map +1 -1
  9. package/dist/cli-help.d.ts +2 -0
  10. package/dist/cli-help.d.ts.map +1 -1
  11. package/dist/cli-help.js +33 -6
  12. package/dist/cli-help.js.map +1 -1
  13. package/dist/clone-cmd.d.ts +28 -0
  14. package/dist/clone-cmd.d.ts.map +1 -0
  15. package/dist/clone-cmd.js +227 -0
  16. package/dist/clone-cmd.js.map +1 -0
  17. package/dist/clone-security.d.ts +9 -0
  18. package/dist/clone-security.d.ts.map +1 -0
  19. package/dist/clone-security.js +41 -0
  20. package/dist/clone-security.js.map +1 -0
  21. package/dist/cubes.js +2 -2
  22. package/dist/cubes.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/launch-all-cmd.d.ts +11 -0
  27. package/dist/launch-all-cmd.d.ts.map +1 -1
  28. package/dist/launch-all-cmd.js +24 -2
  29. package/dist/launch-all-cmd.js.map +1 -1
  30. package/dist/log-stream.d.ts +6 -2
  31. package/dist/log-stream.d.ts.map +1 -1
  32. package/dist/log-stream.js +36 -11
  33. package/dist/log-stream.js.map +1 -1
  34. package/dist/opencode-drone.d.ts +7 -2
  35. package/dist/opencode-drone.d.ts.map +1 -1
  36. package/dist/opencode-drone.js +291 -64
  37. package/dist/opencode-drone.js.map +1 -1
  38. package/dist/opencode-plugin.d.ts.map +1 -1
  39. package/dist/opencode-plugin.js +12 -2
  40. package/dist/opencode-plugin.js.map +1 -1
  41. package/dist/parse-clone-args.d.ts +17 -0
  42. package/dist/parse-clone-args.d.ts.map +1 -0
  43. package/dist/parse-clone-args.js +38 -0
  44. package/dist/parse-clone-args.js.map +1 -0
  45. package/dist/parse-quickstart-args.d.ts +18 -0
  46. package/dist/parse-quickstart-args.d.ts.map +1 -0
  47. package/dist/parse-quickstart-args.js +43 -0
  48. package/dist/parse-quickstart-args.js.map +1 -0
  49. package/dist/quickstart-cmd.d.ts +23 -0
  50. package/dist/quickstart-cmd.d.ts.map +1 -0
  51. package/dist/quickstart-cmd.js +357 -0
  52. package/dist/quickstart-cmd.js.map +1 -0
  53. package/dist/remote-client.d.ts +13 -0
  54. package/dist/remote-client.d.ts.map +1 -1
  55. package/dist/remote-client.js +25 -0
  56. package/dist/remote-client.js.map +1 -1
  57. package/dist/seat-commands.js +12 -12
  58. package/dist/seat-commands.js.map +1 -1
  59. package/dist/stream-status.d.ts.map +1 -1
  60. package/dist/stream-status.js +1 -0
  61. package/dist/stream-status.js.map +1 -1
  62. package/dist/unknown-subcommand.d.ts +1 -1
  63. package/dist/unknown-subcommand.d.ts.map +1 -1
  64. package/dist/unknown-subcommand.js +3 -1
  65. package/dist/unknown-subcommand.js.map +1 -1
  66. package/docs/RELEASING.md +94 -23
  67. package/package.json +1 -1
  68. package/src/assimilate-cmd.ts +35 -12
  69. package/src/claude.ts +25 -3
  70. package/src/cli-help.ts +39 -6
  71. package/src/clone-cmd.ts +243 -0
  72. package/src/clone-security.ts +40 -0
  73. package/src/cubes.ts +2 -2
  74. package/src/index.ts +4 -1
  75. package/src/launch-all-cmd.ts +37 -2
  76. package/src/log-stream.ts +49 -12
  77. package/src/opencode-drone.ts +328 -65
  78. package/src/opencode-plugin.ts +13 -1
  79. package/src/parse-clone-args.ts +44 -0
  80. package/src/parse-quickstart-args.ts +54 -0
  81. package/src/quickstart-cmd.ts +396 -0
  82. package/src/remote-client.ts +36 -0
  83. package/src/seat-commands.ts +12 -12
  84. package/src/stream-status.ts +3 -0
  85. package/src/unknown-subcommand.ts +3 -1
package/docs/RELEASING.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Publishing `borgmcp`
2
2
 
3
- The GitHub Actions workflow publishes one immutable, reviewed `borgmcp` version
4
- from a protected annotated tag. The protected publish job uses npm Trusted
5
- Publishing; no long-lived npm token is stored or exposed.
3
+ The GitHub Actions workflow submits one immutable, reviewed `borgmcp` tarball to
4
+ npm staged publishing from a protected annotated tag. A designated operator
5
+ later approves that stage to make the version live. The protected stage job uses
6
+ npm Trusted Publishing; no long-lived npm token is stored or exposed.
6
7
 
7
8
  ## Release Integrity
8
9
 
@@ -22,9 +23,9 @@ Before creating the release tag, independently verify all of these conditions:
22
23
  - the exact audited registry dependency `borgmcp-shared@0.11.0` remains locked to
23
24
  its canonical tarball and integrity
24
25
  `sha512-I8mixCbSrLKyOAAyqEI/HZJ8cML2rz3r812Up8pr547OdAk9LxZevdCo7ojG42ZwrUmS5u7iKQPg7Vk1XvtX1g==`;
25
- - the coupled client/server release is published only after the server artifact
26
- is rebuilt against `borgmcp-shared@0.11.0` and both pass the complete local
27
- dogfood gate;
26
+ - the coupled shared/server/client candidates have all been built against the
27
+ same exact `borgmcp-shared` version and passed the complete local dogfood gate
28
+ before any stage is approved;
28
29
  - the selected stable client version is unused and the exact release commit is
29
30
  on protected `main`;
30
31
  - the repository and protected npm environment settings pass an operator audit;
@@ -54,7 +55,9 @@ Before preparing a candidate, independently verify:
54
55
  existing `borgmcp` package. It must contain no npm token.
55
56
  2. npm Trusted Publishing is configured for organization `Byte-Ventures`,
56
57
  repository `borg-mcp-client`, workflow `publish.yml`, and environment
57
- `npm-publish`.
58
+ `npm-publish`. Its allowed actions enable `npm stage publish` and disable
59
+ direct `npm publish`; package publishing requires two-factor authentication
60
+ and disallows tokens.
58
61
  3. `refs/tags/v*.*.*` cannot be updated, deleted, or force-moved. Release tags
59
62
  are annotated, match the package version, and point to a commit on protected
60
63
  `main`.
@@ -292,40 +295,97 @@ The unprivileged `verify` job performs one sequence:
292
295
  release artifact.
293
296
 
294
297
  After `verify` succeeds, the designated Queen operator alone approves the
295
- `npm-publish` environment. There is no separate pre-publication exact-artifact
298
+ `npm-publish` environment. There is no separate pre-stage exact-artifact
296
299
  Security gate: the verify job is the mechanical authority for the exact bytes
297
- that the publish job consumes. Environment approval authorizes publication; it
298
- does not permit a rerun, a rebuilt artifact, or approval by another actor.
300
+ that the stage job consumes. Environment approval authorizes submitting those
301
+ bytes to npm's private staged-publishing service; it does not make the version
302
+ public, permit a rerun or rebuilt artifact, or authorize approval by another
303
+ actor.
299
304
 
300
305
  The protected `publish` job alone receives `id-token: write`. It downloads the
301
306
  same-run artifact and rejects a report whose package name or version differs
302
307
  from the release, a version that already exists, an unclaimed package, or an
303
308
  owner set that differs from `NPM_EXPECTED_OWNER`. It requires the GitHub OIDC
304
- request context, rejects a legacy `NODE_AUTH_TOKEN`, and publishes the exact
305
- tarball path once with lifecycle scripts disabled and provenance enabled. It
309
+ request context, rejects a legacy `NODE_AUTH_TOKEN`, and stages the exact tarball
310
+ path once with lifecycle scripts disabled and provenance enabled. It
306
311
  does not install project dependencies, rebuild, retest, repack, or reverify the
307
312
  package.
308
313
 
309
- Successful completion of `npm publish` is the terminal release boundary. There
310
- is no post-publication registry readback job: registry metadata and install
311
- visibility propagate asynchronously and cannot invalidate an immutable
312
- publication after npm accepts it.
314
+ Successful completion of the workflow means npm accepted the immutable staged
315
+ tarball. It does not mean the version is public. Stage acceptance consumes the
316
+ tagged attempt and version under the burned-version rule, but must not trigger
317
+ release announcements, issue closure, consumer pins, site synchronization, or
318
+ claims that the version was published. There is no workflow registry readback:
319
+ stage inspection and approval require an interactive npm identity and cannot use
320
+ the workflow's OIDC credential.
321
+
322
+ ### Coupled stage approval
323
+
324
+ Before approving anything, the operator requires successful stage workflows for
325
+ `borgmcp-shared`, `borgmcp-server`, and `borgmcp`. Use authenticated
326
+ `npm stage list` and `npm stage view <UUID>` to record and inspect all three stage
327
+ UUIDs and verify each package, version, and eventual `latest` tag. For every
328
+ package, `npm stage download <UUID>` is mandatory; compute its SHA-512 SRI and
329
+ require an exact match with that workflow run's same-run artifact report. Bind
330
+ the source annotated tag object, commit, and workflow run separately from GitHub
331
+ evidence, because npm stage inspection does not expose them. Exercise the exact
332
+ downloaded coupled set before approval. Confirm that public `latest`, public
333
+ `versions`, and client update resolution still expose the prior coherent release
334
+ set. If a pending stage appears on any public surface, approve nothing and halt
335
+ this mechanism before consumers can observe it.
336
+
337
+ Approve the verified UUIDs in one operator session, using interactive 2FA, in
338
+ this exact order:
339
+
340
+ 1. `npm stage approve <shared-stage-uuid>`
341
+ 2. `npm stage approve <server-stage-uuid>`
342
+ 3. `npm stage approve <client-stage-uuid>`
343
+
344
+ The approvals are not atomic. Shared approval leaves existing client and server
345
+ pins coherent. Server approval opens a bounded mismatch window between public
346
+ server `latest` and client `latest`; client approval closes it. After an approval
347
+ returns successfully, continue only while the next stage remains valid. For an
348
+ ambiguous result, inspect authenticated stage state and canonical public package
349
+ version/integrity before acting; never repeat an approval blindly.
350
+
351
+ The terminal release boundary is successful interactive stage approval followed
352
+ by canonical registry visibility, integrity verification, and registry
353
+ provenance-attestation inspection. Stage inspection cannot establish provenance
354
+ before approval; Trusted Publishing carries it to the live package, where the
355
+ registry attestation is authoritative. Only then may the release be announced,
356
+ issues closed, consumer pins or site data synchronized, or the version described
357
+ as published.
358
+
359
+ After all three packages cross that boundary, create their GitHub Releases in
360
+ the same shared → server → client operator session. In each package repository,
361
+ run:
362
+
363
+ ```sh
364
+ GITHUB_TOKEN="$(gh auth token)" node scripts/create-github-release.mjs <version>
365
+ ```
366
+
367
+ The script binds the annotated tag, merged release pull request, successful tag
368
+ workflow artifact, and live npm integrity before creating the Release. It copies
369
+ the merged release pull request body verbatim and refuses an existing Release.
313
370
 
314
371
  Separately, once the release is installable from the canonical registry, install
315
372
  it into an isolated prefix and exercise the real user update path end to end.
316
- This is product verification, not publication validation: failure routes a new
373
+ This is product verification, not candidate validation: failure routes a new
317
374
  reviewed fix and never invalidates, rebuilds, retags, or reruns the immutable
318
- release. Do not repeat byte comparisons, integrity/SRI checks, packed-version
319
- checks, source-tree verification, dist-tag readback, or provenance readback that
320
- the exact-artifact `verify` and publish jobs already completed.
375
+ release. Do not repeat byte comparisons, packed-version checks, source-tree
376
+ verification, dist-tag readback, or provenance reconstruction that the
377
+ exact-artifact `verify` and stage jobs already completed. The canonical
378
+ live-version integrity check is required because it establishes the approval
379
+ boundary; it is not a duplicate candidate verification.
321
380
 
322
381
  No separate checksum file is needed: the tarball verifier records canonical
323
382
  SHA-512 SRI in the artifact report. GitHub's same-run artifact transport and the
324
383
  report bind the reviewed candidate without repeated SHA512 choreography.
325
384
 
326
- Rely on npm Trusted Publishing. Do not perform post-publication provenance
327
- readback or reconstruct DSSE, in-toto, SLSA, workflow-ref, or builder statements
328
- locally.
385
+ Rely on npm Trusted Publishing. Perform the one authoritative registry
386
+ provenance-attestation inspection at the approved-live boundary; do not
387
+ reconstruct DSSE, in-toto, SLSA, workflow-ref, or builder statements locally or
388
+ add redundant provenance readback.
329
389
  Do not add cross-run tuple variables, cross-run artifact selection, duplicate
330
390
  builds, duplicate package verification, checksum bundles, or SBOM ceremony.
331
391
 
@@ -336,3 +396,14 @@ authorization evidence is missing or inconsistent. Never move or reuse a failed
336
396
  tag, rerun a failed release workflow, overwrite an npm version, unpublish to hide
337
397
  a failure, or substitute a local rebuild. Recovery starts from a fresh reviewed
338
398
  source change and, after any registry mutation, a separately authorized version.
399
+
400
+ Before any approval, missing or inconsistent stage evidence means approve none.
401
+ Reject all three stages only when abandoning the coupled candidate; Borg treats
402
+ every rejected or unusable tagged stage as burned, even though npm permits some
403
+ re-staging. Replacement versions require fresh review, tags, and stage runs.
404
+ After shared approval but before server approval, stopping is compatibility-safe
405
+ for the existing client/server pair, but shared is immutable and any repair uses
406
+ fresh versions. After server approval, prioritize the already-verified client
407
+ approval because the public pair is mismatched. If that client stage cannot be
408
+ approved, do not reject it or rerun the tag; prepare newly reviewed matching
409
+ server/client recovery versions and state the live mismatch window explicitly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -126,6 +126,21 @@ export interface AssimilateArgs {
126
126
  mode?: 'assimilate' | 'cube-init';
127
127
  }
128
128
 
129
+ export interface PreparedAssimilation {
130
+ cubeId: string;
131
+ cubeName: string;
132
+ droneId: string;
133
+ droneLabel: string;
134
+ roleName: string;
135
+ worktree: string;
136
+ }
137
+
138
+ export interface RunAssimilateOptions {
139
+ /** Internal composition seam for quickstart; the public assimilate command always launches. */
140
+ launch?: boolean;
141
+ onPrepared?: (prepared: PreparedAssimilation) => void;
142
+ }
143
+
129
144
  export interface CubeSummary {
130
145
  id: string;
131
146
  name: string;
@@ -702,7 +717,8 @@ function diagnoseSessionTermination(
702
717
 
703
718
  export async function runAssimilate(
704
719
  args: AssimilateArgs,
705
- deps: AssimilateDeps
720
+ deps: AssimilateDeps,
721
+ options: RunAssimilateOptions = {},
706
722
  ): Promise<number> {
707
723
  const mode = args.mode ?? 'assimilate';
708
724
  // ----- Input validation (before any subprocess work) -----
@@ -2033,6 +2049,24 @@ export async function runAssimilate(
2033
2049
  /* gh#793: orphan GC is best-effort — never block or fail the assimilate */
2034
2050
  }
2035
2051
 
2052
+ // The project hook belongs to a prepared drone, not to the terminal handoff.
2053
+ // Quickstart suppresses only that handoff and later launches through launch-all.
2054
+ try {
2055
+ deps.installProjectSessionHook(agentCwd);
2056
+ } catch {
2057
+ deps.stderr(`warning: could not install the project-local SessionStart hook in ${agentCwd}; it will be re-attempted on the next borg launch\n`);
2058
+ }
2059
+
2060
+ options.onPrepared?.({
2061
+ cubeId: result.cube_id,
2062
+ cubeName: cubeDetail.name,
2063
+ droneId: result.drone_id,
2064
+ droneLabel: result.drone_label,
2065
+ roleName: assignedRole.name,
2066
+ worktree: seatWorktree,
2067
+ });
2068
+ if (options.launch === false) return 0;
2069
+
2036
2070
  // ----- Step 8: Launch selected agent CLI -----
2037
2071
  // Mirrors the kickoff invocation from claude.ts (no-args path): the agent
2038
2072
  // picks up the newly-persisted ActiveCube via the MCP stdio server on
@@ -2060,17 +2094,6 @@ export async function runAssimilate(
2060
2094
  ),
2061
2095
  );
2062
2096
 
2063
- // gh#673 P2 (WI-1): install the project-local SessionStart orientation
2064
- // hook into the launch root — covers BOTH the freshly-spawned sibling
2065
- // worktree (agentCwd = the new worktree post-chdir) and the in-place /
2066
- // --here path. Best-effort: a hook-install failure must never block
2067
- // the assimilate (the bare-`borg` launcher re-ensures it).
2068
- try {
2069
- deps.installProjectSessionHook(agentCwd);
2070
- } catch {
2071
- deps.stderr(`warning: could not install the project-local SessionStart hook in ${agentCwd}; it will be re-attempted on the next borg launch\n`);
2072
- }
2073
-
2074
2097
  // BUG-5 / v0.9.3: probe MCP readiness before launching claude so
2075
2098
  // the launched session sees tools at startup. Non-blocking: probe
2076
2099
  // failure surfaces a stderr warning but the launch proceeds (the
package/src/claude.ts CHANGED
@@ -42,8 +42,12 @@ import { runSpawn } from './spawn.js';
42
42
  import { buildClaudeLaunchArgs } from './claude-launch-args.js';
43
43
  import { parseCleanupArgs, runCleanup } from './cleanup-cmd.js';
44
44
  import { parseAssimilateArgs } from './parse-assimilate-args.js';
45
+ import { parseQuickstartArgs } from './parse-quickstart-args.js';
46
+ import { parseCloneArgs, safeCloneParseError } from './parse-clone-args.js';
45
47
  import { runAssimilate } from './assimilate-cmd.js';
46
48
  import { buildDefaultAssimilateDeps } from './assimilate-deps.js';
49
+ import { buildDefaultQuickstartDeps, runQuickstart } from './quickstart-cmd.js';
50
+ import { buildDefaultCloneDeps, runClone } from './clone-cmd.js';
47
51
  import {
48
52
  parseResetLocalSeatArgs,
49
53
  runResetLocalSeat,
@@ -251,6 +255,24 @@ async function main() {
251
255
  const code = await runAssimilateEntry(process.argv.slice(3));
252
256
  process.exit(code);
253
257
  }
258
+ if (process.argv[2] === 'clone') {
259
+ const parsed = parseCloneArgs(process.argv.slice(3));
260
+ if (!parsed.ok) {
261
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg clone: ${safeCloneParseError(parsed)}\n`));
262
+ process.stderr.write(`Run \`borg clone --help\` for usage.\n`);
263
+ process.exit(1);
264
+ }
265
+ process.exit(await runClone(parsed.args, buildDefaultCloneDeps()));
266
+ }
267
+ if (process.argv[2] === 'quickstart') {
268
+ const parsed = parseQuickstartArgs(process.argv.slice(3));
269
+ if (!parsed.ok) {
270
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg quickstart: ${parsed.error}\n`));
271
+ process.stderr.write(`Run \`borg quickstart --help\` for usage.\n`);
272
+ process.exit(1);
273
+ }
274
+ process.exit(await runQuickstart(parsed.args, buildDefaultQuickstartDeps()));
275
+ }
254
276
  if (process.argv[2] === 'reset-local-connection') {
255
277
  const parsed = parseResetLocalSeatArgs(process.argv.slice(3));
256
278
  if (!parsed.ok) {
@@ -301,11 +323,11 @@ async function main() {
301
323
  const code = await runCleanup({}, parsed.options);
302
324
  process.exit(code);
303
325
  }
304
- if (process.argv[2] === 'seats') {
326
+ if (process.argv[2] === 'drones') {
305
327
  const parsed = parseSeatsArgs(process.argv.slice(3));
306
328
  if (!parsed.ok) {
307
- process.stderr.write(chalk.red(`${consolePrefix()}◼ borg seats: ${parsed.error}\n`));
308
- process.stderr.write(`Run \`borg seats --help\` for usage.\n`);
329
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg drones: ${parsed.error}\n`));
330
+ process.stderr.write(`Run \`borg drones --help\` for usage.\n`);
309
331
  process.exit(1);
310
332
  }
311
333
  process.exit(await runSeats(buildDefaultSeatCommandDeps()));
package/src/cli-help.ts CHANGED
@@ -51,12 +51,41 @@ export function launchAllHelpText(version: string): string {
51
51
  );
52
52
  }
53
53
 
54
+ export function quickstartHelpText(version: string): string {
55
+ return (
56
+ `borg quickstart (borgmcp ${version}) — create, staff, and launch this repository's cube\n\n` +
57
+ `Usage:\n` +
58
+ ` borg quickstart [options]\n\n` +
59
+ `Options:\n` +
60
+ ` --template ${NEW_CUBE_TEMPLATE_OPTIONS} Choose the new-cube template without prompting\n` +
61
+ ` --role <slug>[:<count>] Fully specify the roster (repeatable)\n` +
62
+ ` --yes, -y Accept the displayed plan\n` +
63
+ ` --help, -h Show this help\n\n` +
64
+ `Quickstart requires a running Borg server and never starts one. Rerun the same\n` +
65
+ `command after a partial failure; existing drones are kept and skipped.\n`
66
+ );
67
+ }
68
+
69
+ export function cloneHelpText(version: string): string {
70
+ return (
71
+ `borg clone (borgmcp ${version}) — clone a repository and run quickstart\n\n` +
72
+ `Usage:\n` +
73
+ ` borg clone <repository-url> [directory] [--no-launch]\n\n` +
74
+ `Options:\n` +
75
+ ` --no-launch Stop after the checkout is ready; do not create or launch a cube\n` +
76
+ ` --help, -h Show this help\n\n` +
77
+ `A repeated command reuses a checkout only when its origin matches. Default\n` +
78
+ `destination names avoid non-repository collisions. Credential-bearing URLs are\n` +
79
+ `refused; use a Git credential helper or SSH configuration instead.\n`
80
+ );
81
+ }
82
+
54
83
  export function seatsHelpText(version: string): string {
55
84
  return (
56
- `borg seats (borgmcp ${version}) — list this machine's registered drones\n\n` +
85
+ `borg drones (borgmcp ${version}) — list this machine's registered drones\n\n` +
57
86
  `Usage:\n` +
58
- ` borg seats Show drone, cube, worktree, agent CLI, and local state\n` +
59
- ` borg seats --help Show this help\n\n` +
87
+ ` borg drones Show drone, cube, worktree, agent CLI, and local state\n` +
88
+ ` borg drones --help Show this help\n\n` +
60
89
  `The local registry belongs to this machine only.\n`
61
90
  );
62
91
  }
@@ -91,11 +120,13 @@ export function clientSubcommandHelpText(
91
120
  if (!args.some(isHelpFlag)) return null;
92
121
  switch (command) {
93
122
  case 'setup': return setupHelpText(version);
123
+ case 'clone': return cloneHelpText(version);
94
124
  case 'assimilate': return assimilateHelpText(version);
125
+ case 'quickstart': return quickstartHelpText(version);
95
126
  case 'reset-local-connection': return resetLocalSeatHelpText(version);
96
127
  case 'recover-enrollment': return recoverEnrollmentHelpText(version);
97
128
  case 'cleanup': return cleanupHelpText(version);
98
- case 'seats': return seatsHelpText(version);
129
+ case 'drones': return seatsHelpText(version);
99
130
  case 'launch': return launchSeatHelpText(version);
100
131
  case 'launch-all': return launchAllHelpText(version);
101
132
  case 'doctor': return doctorHelpText(version);
@@ -107,7 +138,7 @@ export function setupNextStepsText(): string {
107
138
  return (
108
139
  `◼ Next steps:\n` +
109
140
  `1. Run \`borg server start\` and leave that terminal open.\n` +
110
- `2. In a second terminal, cd into your project's Git repository and run \`borg assimilate\`.\n`
141
+ `2. In a second terminal, cd into your project's Git repository and run \`borg quickstart\`.\n`
111
142
  );
112
143
  }
113
144
 
@@ -129,6 +160,8 @@ export function topLevelHelpText(version: string): string {
129
160
  ` borg setup Set up borg MCP server + agent CLI integration\n` +
130
161
  ` borg update Update the client and installed local server together\n` +
131
162
  ` borg doctor Check agent hook commands, versions, configs, and the OpenCode plugin\n` +
163
+ ` borg clone <url> [dir] Clone a repository, then create and launch its cube\n` +
164
+ ` borg quickstart Create a cube and a drone for every role, then launch them\n` +
132
165
  ` borg assimilate [role] Join or create a cube\n` +
133
166
  ` borg assimilate --host <host> Join or create on an explicit server\n` +
134
167
  ` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
@@ -136,7 +169,7 @@ export function topLevelHelpText(version: string): string {
136
169
  ` borg reset-local-connection Clear ONLY this worktree's saved connection to its cube (offline; after a rejection)\n` +
137
170
  ` borg recover-enrollment Restore or clear ONLY one failed server enrollment transaction\n` +
138
171
  ` borg cleanup [--prune] Report (or --prune) worktrees orphaned by evicted drones\n` +
139
- ` borg seats List this machine's registered drones and worktrees\n` +
172
+ ` borg drones List this machine's registered drones and worktrees\n` +
140
173
  ` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
141
174
  ` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
142
175
  ` borg server <command> [arguments]\n` +
@@ -0,0 +1,243 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { basename, dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { hasCloneCredentials, redactCloneSecrets } from './clone-security.js';
6
+ import type { CloneArgs } from './parse-clone-args.js';
7
+ import { buildDefaultQuickstartDeps, runQuickstart } from './quickstart-cmd.js';
8
+ import { shellEscape } from './shell-escape.js';
9
+
10
+ export interface GitRunResult {
11
+ status: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ }
15
+
16
+ export interface CloneDeps {
17
+ cwd: () => string;
18
+ chdir: (path: string) => void;
19
+ runSync: (cmd: string, args: string[], cwd?: string) => GitRunResult;
20
+ pathExists: (path: string) => boolean;
21
+ isDirectory: (path: string) => boolean;
22
+ readDirectory: (path: string) => string[];
23
+ createDirectory: (path: string) => boolean;
24
+ removeTree: (path: string) => void;
25
+ quickstart: (cwd: string) => Promise<number>;
26
+ stdout: (text: string) => void;
27
+ stderr: (text: string) => void;
28
+ }
29
+
30
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
31
+ const ALLOWED_SCHEMES = new Set(['file:', 'git:', 'git+ssh:', 'http:', 'https:', 'ssh:']);
32
+ const SCP_REMOTE_RE = /^[^@\s/:]+@[^:\s]+:[^\s]+$/;
33
+
34
+ function defaultRunSync(cmd: string, args: string[], cwd?: string): GitRunResult {
35
+ const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
36
+ return {
37
+ status: result.status,
38
+ stdout: result.stdout ?? '',
39
+ stderr: result.stderr ?? (result.error instanceof Error ? result.error.message : ''),
40
+ };
41
+ }
42
+
43
+ export function buildDefaultCloneDeps(): CloneDeps {
44
+ return {
45
+ cwd: () => process.cwd(),
46
+ chdir: (path) => process.chdir(path),
47
+ runSync: defaultRunSync,
48
+ pathExists: existsSync,
49
+ isDirectory: (path) => {
50
+ try { return statSync(path).isDirectory(); } catch { return false; }
51
+ },
52
+ readDirectory: (path) => readdirSync(path),
53
+ createDirectory: (path) => {
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ try {
56
+ mkdirSync(path);
57
+ return true;
58
+ } catch (error) {
59
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') return false;
60
+ throw error;
61
+ }
62
+ },
63
+ removeTree: (path) => rmSync(path, { recursive: true, force: true }),
64
+ quickstart: async (cwd) => {
65
+ process.chdir(cwd);
66
+ return runQuickstart({ roles: [], yes: false }, buildDefaultQuickstartDeps());
67
+ },
68
+ stdout: (text) => process.stdout.write(text),
69
+ stderr: (text) => process.stderr.write(text),
70
+ };
71
+ }
72
+
73
+ export function validateCloneRepositoryUrl(value: string): { ok: true } | { ok: false; error: string } {
74
+ if (!value || value.trim() !== value || /\s/.test(value) || CONTROL_RE.test(value)) {
75
+ return { ok: false, error: 'repository URL contains whitespace or control characters' };
76
+ }
77
+ if (value.startsWith('-')) return { ok: false, error: 'repository URL must not start with a hyphen' };
78
+ if (hasCloneCredentials(value)) {
79
+ return { ok: false, error: 'credential-bearing repository URLs are not accepted; use a credential helper or SSH configuration instead' };
80
+ }
81
+ if (SCP_REMOTE_RE.test(value)) return { ok: true };
82
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1];
83
+ if (!scheme) return { ok: true }; // Local path.
84
+ if (!value.toLowerCase().startsWith(`${scheme.toLowerCase()}://`) && scheme.toLowerCase() !== 'file') {
85
+ return { ok: false, error: 'repository URL is not a supported hierarchical URL' };
86
+ }
87
+ try {
88
+ const parsed = new URL(value);
89
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) return { ok: false, error: `unsupported repository URL scheme ${parsed.protocol}` };
90
+ if (parsed.search || parsed.hash) return { ok: false, error: 'repository URLs with query strings or fragments are not accepted' };
91
+ if (parsed.protocol !== 'file:' && !parsed.hostname) return { ok: false, error: 'repository URL must include a host' };
92
+ if (!parsed.pathname || parsed.pathname === '/') return { ok: false, error: 'repository URL must include a repository path' };
93
+ return { ok: true };
94
+ } catch {
95
+ return { ok: false, error: 'repository URL is not valid' };
96
+ }
97
+ }
98
+
99
+ function sourceName(value: string): string {
100
+ let leaf = value;
101
+ try {
102
+ const parsed = new URL(value);
103
+ leaf = parsed.protocol === 'file:' ? fileURLToPath(parsed) : parsed.pathname;
104
+ } catch {
105
+ if (SCP_REMOTE_RE.test(value)) leaf = value.slice(value.indexOf(':') + 1);
106
+ }
107
+ const name = basename(leaf.replace(/\/+$/, '')).replace(/\.git$/i, '')
108
+ .replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
109
+ return name || 'repository';
110
+ }
111
+
112
+ function remoteKey(value: string, base: string): string {
113
+ if (SCP_REMOTE_RE.test(value)) {
114
+ const colon = value.indexOf(':');
115
+ const login = value.slice(0, colon);
116
+ const at = login.lastIndexOf('@');
117
+ const user = login.slice(0, at);
118
+ const host = login.slice(at + 1).toLowerCase();
119
+ return `ssh://${user}@${host}/${value.slice(colon + 1).replace(/\/+$/, '').replace(/\.git$/i, '')}`;
120
+ }
121
+ try {
122
+ const parsed = new URL(value);
123
+ if (parsed.protocol === 'file:') return `file:${resolve(fileURLToPath(parsed))}`;
124
+ const user = parsed.username ? `${parsed.username}@` : '';
125
+ const port = parsed.port ? `:${parsed.port}` : '';
126
+ return `${parsed.protocol}//${user}${parsed.hostname.toLowerCase()}${port}${parsed.pathname.replace(/\/+$/, '').replace(/\.git$/i, '')}`;
127
+ } catch {
128
+ return `file:${resolve(base, value)}`;
129
+ }
130
+ }
131
+
132
+ function readOrigin(deps: CloneDeps, directory: string): string | null {
133
+ const result = deps.runSync('git', ['remote', 'get-url', 'origin'], directory);
134
+ return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null;
135
+ }
136
+
137
+ function repositoryAt(deps: CloneDeps, directory: string): boolean {
138
+ return deps.runSync('git', ['rev-parse', '--is-inside-work-tree'], directory).status === 0;
139
+ }
140
+
141
+ function validateDestination(value: string): string | null {
142
+ if (!value || value.trim() !== value || CONTROL_RE.test(value) || value.startsWith('-') || hasCloneCredentials(value)) {
143
+ return 'destination contains an unsafe path value';
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function chooseDestination(deps: CloneDeps, args: CloneArgs): { path: string } | { error: string } {
149
+ const base = deps.cwd();
150
+ if (args.destination !== undefined) {
151
+ const error = validateDestination(args.destination);
152
+ return error ? { error } : { path: resolve(base, args.destination) };
153
+ }
154
+ const name = sourceName(args.repositoryUrl);
155
+ for (let n = 1; n < 10_000; n += 1) {
156
+ const candidate = resolve(base, n === 1 ? name : `${name}-${n}`);
157
+ if (!deps.pathExists(candidate)) return { path: candidate };
158
+ if (deps.isDirectory(candidate) && repositoryAt(deps, candidate)) {
159
+ const origin = readOrigin(deps, candidate);
160
+ if (origin && !hasCloneCredentials(origin) && remoteKey(origin, candidate) === remoteKey(args.repositoryUrl, base)) {
161
+ return { path: candidate };
162
+ }
163
+ if (n === 1) return { error: `existing checkout at ${candidate} points at another remote; choose an explicit destination` };
164
+ }
165
+ }
166
+ return { error: `could not find a collision-safe destination for ${name}` };
167
+ }
168
+
169
+ export async function runClone(args: CloneArgs, rawDeps: CloneDeps): Promise<number> {
170
+ const deps: CloneDeps = {
171
+ ...rawDeps,
172
+ stdout: (text) => rawDeps.stdout(redactCloneSecrets(text)),
173
+ stderr: (text) => rawDeps.stderr(redactCloneSecrets(text)),
174
+ };
175
+ const valid = validateCloneRepositoryUrl(args.repositoryUrl);
176
+ if (!valid.ok) {
177
+ deps.stderr(`borg clone: ${valid.error}.\n`);
178
+ return 1;
179
+ }
180
+ const selected = chooseDestination(deps, args);
181
+ if ('error' in selected) {
182
+ deps.stderr(`borg clone: ${selected.error}.\n`);
183
+ return 1;
184
+ }
185
+ const destination = selected.path;
186
+ let cloned = false;
187
+ const destinationExisted = deps.pathExists(destination);
188
+ const emptyDestination = destinationExisted && deps.isDirectory(destination) && deps.readDirectory(destination).length === 0;
189
+ if (destinationExisted && !emptyDestination) {
190
+ if (!deps.isDirectory(destination) || !repositoryAt(deps, destination)) {
191
+ deps.stderr(`borg clone: destination ${destination} exists and is not a Git checkout; it was left untouched.\n`);
192
+ return 1;
193
+ }
194
+ const origin = readOrigin(deps, destination);
195
+ if (!origin || hasCloneCredentials(origin)) {
196
+ deps.stderr(`borg clone: the existing checkout has no safe, readable origin remote; it was left untouched.\n`);
197
+ return 1;
198
+ }
199
+ if (remoteKey(origin, destination) !== remoteKey(args.repositoryUrl, deps.cwd())) {
200
+ deps.stderr(`borg clone: remote mismatch at ${destination}; existing ${origin}, requested ${args.repositoryUrl}. The checkout was left untouched.\n`);
201
+ return 1;
202
+ }
203
+ deps.stdout(`Reusing existing checkout at ${destination}; its origin remote matches.\n`);
204
+ } else {
205
+ const createdDestination = destinationExisted ? false : deps.createDirectory(destination);
206
+ if (!destinationExisted && !createdDestination) {
207
+ deps.stderr(`borg clone: destination ${destination} appeared while preparing the clone; it was left untouched. Retry with another directory.\n`);
208
+ return 1;
209
+ }
210
+ const result = deps.runSync('git', ['clone', '--', args.repositoryUrl, destination], deps.cwd());
211
+ if (result.status !== 0) {
212
+ if (createdDestination && deps.pathExists(destination)) {
213
+ deps.removeTree(destination);
214
+ }
215
+ deps.stderr(
216
+ `borg clone: clone failed${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}.\n` +
217
+ `Rollback: ${destinationExisted
218
+ ? `the pre-existing empty destination ${destination} was preserved; inspect it for partial Git files`
219
+ : deps.pathExists(destination) ? `partial checkout remains at ${destination}` : `the directory borg created at ${destination} was removed`}.\n`,
220
+ );
221
+ return 1;
222
+ }
223
+ cloned = true;
224
+ deps.stdout(`Cloned ${sourceName(args.repositoryUrl)} into ${destination}.\n`);
225
+ }
226
+
227
+ if (args.noLaunch) {
228
+ deps.stdout(
229
+ `Checkout ready at ${destination}. No cube or drone was created.\n` +
230
+ `Next: cd ${shellEscape(destination)} && borg quickstart\n`,
231
+ );
232
+ return 0;
233
+ }
234
+ deps.chdir(destination);
235
+ const code = await deps.quickstart(destination);
236
+ if (code !== 0) {
237
+ deps.stderr(
238
+ `The checkout${cloned ? '' : ' you already had'} is ready at ${destination}, but quickstart did not finish. ` +
239
+ `It was left untouched; cd there and run \`borg quickstart\` again.\n`,
240
+ );
241
+ }
242
+ return code;
243
+ }