borgmcp 2.0.10 → 2.1.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 (73) hide show
  1. package/README.md +11 -3
  2. package/SECURITY.md +1 -1
  3. package/dist/assimilate-cmd.d.ts +13 -4
  4. package/dist/assimilate-cmd.d.ts.map +1 -1
  5. package/dist/assimilate-cmd.js +134 -110
  6. package/dist/assimilate-cmd.js.map +1 -1
  7. package/dist/assimilate-deps.d.ts +7 -1
  8. package/dist/assimilate-deps.d.ts.map +1 -1
  9. package/dist/assimilate-deps.js +44 -18
  10. package/dist/assimilate-deps.js.map +1 -1
  11. package/dist/claude.d.ts +3 -1
  12. package/dist/claude.d.ts.map +1 -1
  13. package/dist/claude.js +26 -12
  14. package/dist/claude.js.map +1 -1
  15. package/dist/cli-help.d.ts +2 -0
  16. package/dist/cli-help.d.ts.map +1 -1
  17. package/dist/cli-help.js +22 -5
  18. package/dist/cli-help.js.map +1 -1
  19. package/dist/config.d.ts +7 -4
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +21 -4
  22. package/dist/config.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -8
  25. package/dist/index.js.map +1 -1
  26. package/dist/local-manage-tool-result.d.ts +10 -0
  27. package/dist/local-manage-tool-result.d.ts.map +1 -1
  28. package/dist/local-manage-tool-result.js +36 -0
  29. package/dist/local-manage-tool-result.js.map +1 -1
  30. package/dist/remote-client.d.ts +8 -5
  31. package/dist/remote-client.d.ts.map +1 -1
  32. package/dist/remote-client.js +54 -11
  33. package/dist/remote-client.js.map +1 -1
  34. package/dist/repository-cube-init.d.ts +57 -0
  35. package/dist/repository-cube-init.d.ts.map +1 -0
  36. package/dist/repository-cube-init.js +196 -0
  37. package/dist/repository-cube-init.js.map +1 -0
  38. package/dist/repository-identity.d.ts +29 -0
  39. package/dist/repository-identity.d.ts.map +1 -0
  40. package/dist/repository-identity.js +143 -0
  41. package/dist/repository-identity.js.map +1 -0
  42. package/dist/server-errors.d.ts +6 -0
  43. package/dist/server-errors.d.ts.map +1 -1
  44. package/dist/server-errors.js +12 -0
  45. package/dist/server-errors.js.map +1 -1
  46. package/dist/server-facade.d.ts +11 -1
  47. package/dist/server-facade.d.ts.map +1 -1
  48. package/dist/server-facade.js +33 -3
  49. package/dist/server-facade.js.map +1 -1
  50. package/dist/server-handshake.d.ts +8 -4
  51. package/dist/server-handshake.d.ts.map +1 -1
  52. package/dist/server-handshake.js +15 -6
  53. package/dist/server-handshake.js.map +1 -1
  54. package/dist/tool-manifest.js +2 -2
  55. package/dist/tool-manifest.js.map +1 -1
  56. package/docs/EXTRACTION_PROVENANCE.md +8 -8
  57. package/docs/LOCAL_SERVER.md +35 -6
  58. package/docs/RELEASING.md +25 -14
  59. package/package.json +2 -2
  60. package/src/assimilate-cmd.ts +166 -140
  61. package/src/assimilate-deps.ts +59 -17
  62. package/src/claude.ts +34 -14
  63. package/src/cli-help.ts +25 -5
  64. package/src/config.ts +30 -8
  65. package/src/index.ts +9 -8
  66. package/src/local-manage-tool-result.ts +47 -0
  67. package/src/remote-client.ts +64 -19
  68. package/src/repository-cube-init.ts +273 -0
  69. package/src/repository-identity.ts +199 -0
  70. package/src/server-errors.ts +14 -0
  71. package/src/server-facade.ts +47 -2
  72. package/src/server-handshake.ts +33 -7
  73. package/src/tool-manifest.ts +2 -2
@@ -7,7 +7,6 @@ import {
7
7
  occupiedRoleIdsForAutoRole,
8
8
  pickDefaultRole,
9
9
  } from './role-resolver.js';
10
- import { deriveCubeName, parseGitRemote, sanitizeRemoteUrl } from './cube-name.js';
11
10
  import { validateName } from './name-validator.js';
12
11
  import { renderAssimilationWelcome } from './assimilate-welcome.js';
13
12
  import { shellEscape } from './shell-escape.js';
@@ -42,7 +41,12 @@ import { installBorgPlugin } from './opencode-plugin.js';
42
41
  import { computeOpenCodePort, connectOpenCodeDrone, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
43
42
  import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
44
43
  import { normalizeServerEndpoint } from './server-endpoint.js';
45
- import { BorgServerError, LegacySessionCredentialCollisionError } from './server-errors.js';
44
+ import {
45
+ BorgServerError,
46
+ CubeCreationConfirmationError,
47
+ CubeCreationOutcomeUnknownError,
48
+ LegacySessionCredentialCollisionError,
49
+ } from './server-errors.js';
46
50
  import type { SeatStatus } from './seat-probe.js';
47
51
  import type { ServerSessionOperation } from './config.js';
48
52
  import type { ExpectedBinding, FinalizeServerSeatOutcome, PersistedLocalSeat } from './cubes.js';
@@ -50,6 +54,14 @@ import type { SeatBinding, BindPendingSeatOutcome } from './seats.js';
50
54
  import { createHash } from 'node:crypto';
51
55
  import { buildOpenCodeLaunchArgs, type LaunchApprovalDecision } from './cli-tool-approval.js';
52
56
  import { resolveWorkingRepo, type WorkingRepo } from './working-repo.js';
57
+ import type { CreateCubeRepository, CubeTemplate } from 'borgmcp-shared/protocol';
58
+ import {
59
+ initializeRepositoryCube,
60
+ RepositoryAssociationSaveError,
61
+ validRepositoryCubeName,
62
+ type RepositoryCubeCreation,
63
+ } from './repository-cube-init.js';
64
+ import type { GitRepositoryContext, RepositoryAssociation } from './repository-identity.js';
53
65
 
54
66
  const PRIVATE_STATE_UNAVAILABLE_COPY = [
55
67
  'Borg could not safely prepare its private local state.',
@@ -73,6 +85,7 @@ export interface AssimilateFlags {
73
85
  export interface AssimilateArgs {
74
86
  role: string | undefined;
75
87
  flags: AssimilateFlags;
88
+ mode?: 'assimilate' | 'cube-init';
76
89
  }
77
90
 
78
91
  export interface CubeSummary {
@@ -222,6 +235,10 @@ export interface AssimilateDeps {
222
235
  scrubPending: () => Promise<unknown>;
223
236
  }) => Promise<FinalizeServerSeatOutcome>;
224
237
  findProjectRoot: (cwd: string) => string;
238
+ resolveRepositoryContext: (cwd: string) => Promise<GitRepositoryContext | null>;
239
+ getRepositoryIdentity: (context: GitRepositoryContext) => Promise<CreateCubeRepository>;
240
+ getRepositoryAssociation: (trustIdentity: string, repository: CreateCubeRepository) => Promise<RepositoryAssociation | null>;
241
+ saveRepositoryAssociation: (trustIdentity: string, repository: CreateCubeRepository, association: RepositoryAssociation) => Promise<void>;
225
242
 
226
243
  // gh#673 P2 (WI-1): write the borg-regen SessionStart hook into the
227
244
  // launch root's .claude/settings.local.json (project-local; idempotent).
@@ -254,9 +271,14 @@ export interface AssimilateDeps {
254
271
  createCube: (
255
272
  apiUrl: string,
256
273
  token: string,
257
- params: { name?: string; template?: string; projectRoot?: string },
274
+ params: {
275
+ name: string;
276
+ workingRepoName: string;
277
+ repository: CreateCubeRepository;
278
+ template: Exclude<CubeTemplate, 'default'>;
279
+ },
258
280
  serverTrustIdentity?: string,
259
- ) => Promise<CubeDetail>;
281
+ ) => Promise<RepositoryCubeCreation>;
260
282
  assimilate: (
261
283
  apiUrl: string,
262
284
  token: string,
@@ -301,11 +323,6 @@ function affirmative(answer: string): boolean {
301
323
  return normalized === '' || normalized === 'y' || normalized === 'yes';
302
324
  }
303
325
 
304
- function isLocalCubePresentationName(name: string): boolean {
305
- return name.length >= 1 && name.length <= 120 &&
306
- /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name);
307
- }
308
-
309
326
  async function selectAssimilationAuthority(
310
327
  flags: AssimilateFlags,
311
328
  deps: AssimilateDeps,
@@ -509,6 +526,7 @@ export async function runAssimilate(
509
526
  args: AssimilateArgs,
510
527
  deps: AssimilateDeps
511
528
  ): Promise<number> {
529
+ const mode = args.mode ?? 'assimilate';
512
530
  // ----- Input validation (before any subprocess work) -----
513
531
  if (args.role !== undefined) {
514
532
  const v = validateName(args.role);
@@ -524,6 +542,29 @@ export async function runAssimilate(
524
542
  return 1;
525
543
  }
526
544
  }
545
+ if (args.flags.cubeName !== undefined && !validRepositoryCubeName(args.flags.cubeName.trim())) {
546
+ deps.stderr('Invalid cube name. Use 1-120 letters, digits, spaces, dots, underscores, or hyphens, starting with a letter or digit.\n');
547
+ return 1;
548
+ }
549
+
550
+ let repositoryContext: GitRepositoryContext | null;
551
+ try {
552
+ repositoryContext = await deps.resolveRepositoryContext(deps.cwd());
553
+ } catch (error) {
554
+ if (error instanceof Error && error.message === 'BARE_REPOSITORY') {
555
+ deps.stderr('borg assimilate requires a non-bare repository worktree. Clone or check out the repository, then retry.\n');
556
+ return 1;
557
+ }
558
+ repositoryContext = null;
559
+ }
560
+ if (!repositoryContext) {
561
+ deps.stderr(
562
+ 'No Git repository was found for this directory.\n' +
563
+ 'Nothing was changed.\n' +
564
+ 'Run this command inside a Git repository.\n',
565
+ );
566
+ return 1;
567
+ }
527
568
 
528
569
  try {
529
570
  await deps.preparePrivateRoot();
@@ -555,86 +596,9 @@ export async function runAssimilate(
555
596
  return reportServerFailure(deps, authority.apiUrl, localSeatReadError);
556
597
  }
557
598
 
558
- // ----- Repository + cube-name preflight -----
559
- // Resolve and, where necessary, confirm local presentation data before an
560
- // owner invitation can be consumed. A declined/underivable basename must
561
- // not leave a successfully enrolled client behind.
562
- const projectRoot = deps.findProjectRoot(deps.cwd());
563
- let cubeName: string | null;
564
- if (args.flags.cubeName) {
565
- cubeName = args.flags.cubeName;
566
- } else {
567
- const remoteResult = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
568
- const remoteUrl = remoteResult.status === 0 ? remoteResult.stdout : null;
569
- const sanitizedRemote = remoteUrl ? sanitizeRemoteUrl(remoteUrl) : null;
570
- const parsedRepo = sanitizedRemote ? parseGitRemote(sanitizedRemote) : null;
571
-
572
- if (!parsedRepo) {
573
- const bareResult = deps.runSync('git', ['rev-parse', '--is-bare-repository'], projectRoot);
574
- if (bareResult.status === 0 && bareResult.stdout.trim() === 'true') {
575
- deps.stderr(
576
- 'borg assimilate requires a non-bare repository worktree. ' +
577
- (authority.kind === 'server'
578
- ? `Clone or check out the repository, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`
579
- : 'Clone or check out the repository, then retry.\n'),
580
- );
581
- return 1;
582
- }
583
- }
599
+ const projectRoot = repositoryContext.root;
584
600
 
585
- cubeName = deriveCubeName(projectRoot, remoteUrl);
586
- if (!cubeName) {
587
- deps.stderr(
588
- 'Could not derive a cube name from this repository. ' +
589
- (authority.kind === 'server'
590
- ? `Rerun ${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`
591
- : 'Pass --cube-name <name> and retry.\n'),
592
- );
593
- return 1;
594
- }
595
-
596
- if (!parsedRepo) {
597
- if (sanitizedRemote) {
598
- deps.stderr(
599
- `Could not parse the origin remote; using directory name '${cubeName}' as the cube name.\n`,
600
- );
601
- }
602
- if (!args.flags.yes) {
603
- if (!deps.isTTY()) {
604
- deps.stderr(
605
- `Using directory name '${cubeName}' as the cube name requires confirmation. ` +
606
- (authority.kind === 'server'
607
- ? `Rerun ${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\` or \`--yes\`.\n`
608
- : 'Re-run with --cube-name <name> or --yes.\n'),
609
- );
610
- return 1;
611
- }
612
- const confirmed = await deps.prompt(
613
- `No usable origin remote was found. Use directory name '${cubeName}' as the cube name? [Y/n]: `,
614
- );
615
- if (!affirmative(confirmed)) {
616
- deps.stderr(
617
- authority.kind === 'server'
618
- ? `Cube creation for ${authority.apiUrl} was cancelled. Rerun ` +
619
- `${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`
620
- : 'Cube creation cancelled. Re-run with --cube-name <name> to choose a name.\n',
621
- );
622
- return 1;
623
- }
624
- }
625
- }
626
- }
627
-
628
- if (authority.kind === 'server' && !isLocalCubePresentationName(cubeName)) {
629
- deps.stderr(
630
- `Invalid cube name for ${authority.apiUrl}. Use 1–120 letters, digits, spaces, dots, ` +
631
- 'underscores, or hyphens, starting with a letter or digit. Rerun ' +
632
- `${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`,
633
- );
634
- return 1;
635
- }
636
-
637
- let auth: { token: string; apiUrl: string; serverTrustIdentity: string };
601
+ let auth: { token: string; apiUrl: string; serverTrustIdentity: string; serverCapabilities: readonly string[] };
638
602
  {
639
603
  try {
640
604
  let serverAuth: {
@@ -696,6 +660,7 @@ export async function runAssimilate(
696
660
  token: serverAuth.token,
697
661
  apiUrl: authority.apiUrl,
698
662
  serverTrustIdentity: serverAuth.trustIdentity,
663
+ serverCapabilities: serverAuth.serverCapabilities ?? [],
699
664
  };
700
665
  } catch (error) {
701
666
  return reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true);
@@ -717,67 +682,128 @@ export async function runAssimilate(
717
682
  // (no chdir has happened yet; this is a stable starting point).
718
683
  const originalCwd = deps.cwd();
719
684
 
720
- // ----- Step 3: Cube existence check (with auto-refresh on auth failure) -----
721
- // gh#653 B4: announce each network step. These calls take 2–5s and were
722
- // previously silent, so a user read the wait as a hang and Ctrl-C'd mid-run.
723
- deps.stderr('Checking your cubes…\n');
724
- let allCubes: CubeSummary[];
685
+ let initialized;
725
686
  try {
726
- allCubes = await deps.listCubes(auth.apiUrl, auth.token, auth.serverTrustIdentity);
727
- } catch (err) {
728
- return reportServerFailure(deps, authority.apiUrl, err);
729
- }
730
- const existingCube = allCubes.find((c) => c.name === cubeName);
731
-
732
- // ----- Step 4: Fetch detail OR create cube -----
733
- let cubeDetail: CubeDetail;
734
- let isFirstDrone: boolean;
735
- if (existingCube) {
736
- try {
737
- cubeDetail = await deps.getCube(
738
- auth.apiUrl,
739
- auth.token,
740
- existingCube.id,
741
- auth.serverTrustIdentity,
687
+ if (!auth.serverCapabilities.includes('create_cube') && !existing) {
688
+ const repository = await deps.getRepositoryIdentity(repositoryContext);
689
+ const association = await deps.getRepositoryAssociation(auth.serverTrustIdentity, repository);
690
+ if (association) {
691
+ initialized = await initializeRepositoryCube({
692
+ mode, context: repositoryContext, serverOrigin: auth.apiUrl, flags: args.flags,
693
+ }, {
694
+ isTTY: deps.isTTY,
695
+ prompt: deps.prompt,
696
+ write: deps.stderr,
697
+ getIdentity: async () => repository,
698
+ getAssociation: async () => association,
699
+ saveAssociation: (identity, saved) =>
700
+ deps.saveRepositoryAssociation(auth.serverTrustIdentity, identity, saved),
701
+ getCube: (cubeId) => deps.getCube(auth.apiUrl, auth.token, cubeId, auth.serverTrustIdentity),
702
+ createCube: (params) => deps.createCube(auth.apiUrl, auth.token, params, auth.serverTrustIdentity),
703
+ });
704
+ } else {
705
+ const candidateName = args.flags.cubeName ?? repositoryContext.derivedName;
706
+ const accessible = await deps.listCubes(auth.apiUrl, auth.token, auth.serverTrustIdentity);
707
+ const matches = accessible.filter((cube) => cube.name === candidateName);
708
+ if (matches.length !== 1) {
709
+ deps.stderr(matches.length === 0
710
+ ? `This enrolled client cannot create a cube on ${auth.apiUrl}. Ask the server operator to grant access to a cube, then rerun ${localAssimilateCommand(auth.apiUrl)}.\n`
711
+ : `More than one accessible cube is named '${candidateName}'. Nothing was changed. Ask the server operator for an unambiguous repository cube grant.\n`);
712
+ return 1;
713
+ }
714
+ const cube = await deps.getCube(auth.apiUrl, auth.token, matches[0].id, auth.serverTrustIdentity);
715
+ const association = {
716
+ cubeId: cube.id,
717
+ name: cube.name,
718
+ workingRepoName: repositoryContext.derivedName,
719
+ template: 'default' as const,
720
+ };
721
+ try {
722
+ await deps.saveRepositoryAssociation(auth.serverTrustIdentity, repository, association);
723
+ } catch {
724
+ throw new RepositoryAssociationSaveError();
725
+ }
726
+ const response = {
727
+ result: 'resolved' as const,
728
+ cube_id: cube.id,
729
+ name: cube.name,
730
+ working_repo_name: repositoryContext.derivedName,
731
+ repository,
732
+ template: 'default' as const,
733
+ human_seat_role_id: cube.roles.find((role) => role.is_human_seat)?.id ?? '',
734
+ default_worker_role_id: cube.roles.find((role) => role.is_default)?.id ?? '',
735
+ access: 'manage' as const,
736
+ };
737
+ deps.stderr(
738
+ `Cube already initialized.\n Name: ${cube.name}\n Template: Default (legacy)\n` +
739
+ ` Repository: ${repositoryContext.root}\n Server: ${auth.apiUrl}\n` +
740
+ (mode === 'cube-init'
741
+ ? `No drone was created.\nNext: borg assimilate --host ${shellEscape(auth.apiUrl)}\n`
742
+ : 'Continuing with role and seat setup...\n'),
743
+ );
744
+ initialized = { kind: 'success' as const, creation: { response, cube }, existing: true };
745
+ }
746
+ } else {
747
+ initialized = await initializeRepositoryCube({
748
+ mode,
749
+ context: repositoryContext,
750
+ serverOrigin: auth.apiUrl,
751
+ flags: args.flags,
752
+ }, {
753
+ isTTY: deps.isTTY,
754
+ prompt: deps.prompt,
755
+ write: deps.stderr,
756
+ getIdentity: deps.getRepositoryIdentity,
757
+ getAssociation: (repository) => deps.getRepositoryAssociation(auth.serverTrustIdentity, repository),
758
+ saveAssociation: (repository, association) =>
759
+ deps.saveRepositoryAssociation(auth.serverTrustIdentity, repository, association),
760
+ getCube: (cubeId) => deps.getCube(auth.apiUrl, auth.token, cubeId, auth.serverTrustIdentity),
761
+ createCube: (params) => deps.createCube(auth.apiUrl, auth.token, params, auth.serverTrustIdentity),
762
+ });
763
+ }
764
+ } catch (error) {
765
+ if (error instanceof RepositoryAssociationSaveError) {
766
+ deps.stderr(
767
+ 'The repository cube was confirmed, but Borg could not save its local repository association.\n' +
768
+ 'No drone was created.\n' +
769
+ 'Run the same command again; the server will resolve the existing cube and restore the local association.\n',
742
770
  );
743
- } catch (error) {
744
- return reportServerFailure(deps, authority.apiUrl, error);
771
+ return 1;
745
772
  }
746
- isFirstDrone = (cubeDetail.drones?.length ?? 0) === 0;
747
- } else {
748
- // ----- Step 4a: First-drone bootstrap (template selection) -----
749
- let chosenTemplate: string | undefined;
750
- if (args.flags.noTemplate ||
751
- (args.flags.template !== undefined && args.flags.template !== 'default')) {
773
+ if (error instanceof CubeCreationOutcomeUnknownError) {
752
774
  deps.stderr(
753
- `Borg server ${authority.apiUrl} supports its default cube template only. ` +
754
- `Rerun ${localAssimilateCommand(authority.apiUrl)} without \`--template\` or \`--no-template\`.\n`,
775
+ 'Cube creation outcome is unknown.\n' +
776
+ 'No local repository association was saved.\n' +
777
+ 'Run the same command again; the server will resolve the original request or return a conflict.\n',
755
778
  );
756
779
  return 1;
757
780
  }
758
- chosenTemplate = 'default';
759
-
760
- // gh#653 B4: progress for the create round-trip (silent-window stall).
761
- deps.stderr(cubeName ? `Creating cube '${cubeName}'…\n` : 'Creating your cube…\n');
762
- try {
763
- const createParams = chosenTemplate
764
- ? {
765
- name: cubeName ?? undefined,
766
- template: chosenTemplate,
767
- projectRoot,
768
- }
769
- : { name: cubeName ?? undefined };
770
- cubeDetail = await deps.createCube(
771
- auth.apiUrl,
772
- auth.token,
773
- createParams,
774
- auth.serverTrustIdentity,
781
+ if (error instanceof CubeCreationConfirmationError) {
782
+ deps.stderr(
783
+ 'Cube creation could not be confirmed.\n' +
784
+ `${error.message}\n` +
785
+ 'No local repository association was saved.\n' +
786
+ 'Resolve the conflict, then run the same command again.\n',
775
787
  );
776
- } catch (error) {
777
- return reportServerFailure(deps, authority.apiUrl, error);
788
+ return 1;
789
+ }
790
+ if (error instanceof BorgServerError && error.code === 'CREATE_CUBE_DENIED') {
791
+ deps.stderr(
792
+ `This enrolled client cannot create a cube on ${auth.apiUrl}. ` +
793
+ `Ask the server operator to grant access to a cube, then rerun ${localAssimilateCommand(auth.apiUrl)}.\n`,
794
+ );
795
+ return 1;
778
796
  }
779
- isFirstDrone = true;
797
+ if (error instanceof BorgServerError) {
798
+ return reportServerFailure(deps, auth.apiUrl, error);
799
+ }
800
+ deps.stderr('Could not create cube.\nThe selected Borg server rejected the request.\nNothing was changed.\n');
801
+ return 1;
780
802
  }
803
+ if (initialized.kind === 'stop') return initialized.code;
804
+ const cubeDetail = initialized.creation.cube as CubeDetail;
805
+ const isFirstDrone = (cubeDetail.drones?.length ?? 0) === 0;
806
+ if (mode === 'cube-init') return 0;
781
807
 
782
808
  // Read the worktree identity before role selection. A live local seat must
783
809
  // retain its original role so the attach request reuses the exact durable
@@ -1754,7 +1780,7 @@ export async function runAssimilate(
1754
1780
  serverUrl,
1755
1781
  directory: agentCwd,
1756
1782
  droneLabel: result.drone_label,
1757
- cubeName: cubeName ?? 'borg',
1783
+ cubeName: cubeDetail.name,
1758
1784
  })
1759
1785
  .then(() => injectInitialKickoff(launchKickoff))
1760
1786
  .catch(() => {});
@@ -61,8 +61,47 @@ import { prepareCodexRemoteLaunch, defaultCodexRemoteDeps } from './codex-remote
61
61
  import { findLoadedCodexThread } from './codex-app-server.js';
62
62
  import { defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
63
63
  import { ensurePrivateBorgConfigRoot } from './private-root.js';
64
+ import {
65
+ getOrCreateRepositoryIdentity,
66
+ getRepositoryAssociation,
67
+ resolveGitRepositoryContext,
68
+ saveRepositoryAssociation,
69
+ } from './repository-identity.js';
70
+ import { PromptInterruptedError } from './repository-cube-init.js';
64
71
 
65
- export function buildDefaultAssimilateDeps(): AssimilateDeps {
72
+ /**
73
+ * Wraps the readline question operation with the production interruption
74
+ * mapping. Tests inject the question operation; production uses readline.
75
+ */
76
+ export type PromptQuestion = (message: string) => Promise<string>;
77
+
78
+ async function defaultPromptQuestion(message: string): Promise<string> {
79
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
80
+ try {
81
+ return await rl.question(message);
82
+ } finally {
83
+ rl.close();
84
+ }
85
+ }
86
+
87
+ export function createPromptAdapter(
88
+ question: PromptQuestion = defaultPromptQuestion,
89
+ ): (message: string) => Promise<string> {
90
+ return async (message: string): Promise<string> => {
91
+ try {
92
+ return await question(message);
93
+ } catch (err) {
94
+ if (err instanceof Error && (err.message === 'SIGINT' || err.message === 'Interrupted by signal.')) {
95
+ throw new PromptInterruptedError();
96
+ }
97
+ throw err;
98
+ }
99
+ };
100
+ }
101
+
102
+ export function buildDefaultAssimilateDeps(
103
+ question: PromptQuestion = defaultPromptQuestion,
104
+ ): AssimilateDeps {
66
105
  return {
67
106
  runSync: (cmd, args, cwd) => {
68
107
  const r = spawnSync(cmd, args, { cwd, encoding: 'utf-8' });
@@ -97,14 +136,7 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
97
136
 
98
137
  stderr: (line) => process.stderr.write(line),
99
138
  stdout: (line) => process.stdout.write(line),
100
- prompt: async (message: string): Promise<string> => {
101
- const rl = createInterface({ input: process.stdin, output: process.stdout });
102
- try {
103
- return await rl.question(message);
104
- } finally {
105
- rl.close();
106
- }
107
- },
139
+ prompt: createPromptAdapter(question),
108
140
  promptSecret: async (message: string): Promise<string> => {
109
141
  const result = await prompts({
110
142
  type: 'password',
@@ -178,6 +210,10 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
178
210
  : { committed: false, reason: 'activation-failed' };
179
211
  },
180
212
  findProjectRoot: (cwd) => cubesFindProjectRoot(cwd),
213
+ resolveRepositoryContext: resolveGitRepositoryContext,
214
+ getRepositoryIdentity: getOrCreateRepositoryIdentity,
215
+ getRepositoryAssociation,
216
+ saveRepositoryAssociation,
181
217
 
182
218
  // gh#673 P2 (WI-1): project-local SessionStart hook for the launch root.
183
219
  installProjectSessionHook: (projectRoot) => {
@@ -237,14 +273,16 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
237
273
  throw new Error('Selected Borg server authority state is missing or unreadable');
238
274
  }
239
275
  {
240
- if (!params.name || !params.projectRoot) {
241
- throw new Error('Local Borg server cube creation requires a repository name and root');
242
- }
243
276
  const created = await createLocalBorgServerCube(
244
277
  apiUrl,
245
278
  serverTrustIdentity,
246
279
  token,
247
- { projectRoot: params.projectRoot, name: params.name },
280
+ {
281
+ name: params.name,
282
+ workingRepoName: params.workingRepoName,
283
+ repository: params.repository,
284
+ template: params.template,
285
+ },
248
286
  );
249
287
  const cube = await remoteGetCube(created.cube_id, {
250
288
  apiUrl,
@@ -253,16 +291,20 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
253
291
  });
254
292
  if (
255
293
  cube.id !== created.cube_id ||
294
+ cube.name !== created.name ||
256
295
  !Array.isArray(cube.roles) ||
257
296
  !cube.roles.some((role: { id?: string }) => role.id === created.default_worker_role_id)
258
297
  ) {
259
298
  throw new Error('Borg server returned cube details outside the creation result');
260
299
  }
261
300
  return {
262
- id: cube.id,
263
- name: cube.name,
264
- roles: cube.roles,
265
- drones: cube.drones ?? [],
301
+ response: created,
302
+ cube: {
303
+ id: cube.id,
304
+ name: cube.name,
305
+ roles: cube.roles,
306
+ drones: cube.drones ?? [],
307
+ },
266
308
  };
267
309
  }
268
310
  },
package/src/claude.ts CHANGED
@@ -19,8 +19,10 @@
19
19
 
20
20
  import { spawn } from 'child_process';
21
21
  import { randomUUID } from 'node:crypto';
22
+ import { realpathSync } from 'node:fs';
22
23
  import { basename } from 'node:path';
23
24
  import { createInterface } from 'node:readline/promises';
25
+ import { fileURLToPath } from 'node:url';
24
26
  import chalk from 'chalk';
25
27
  import { findProjectRoot, getActiveCube, inboxPathForDrone, setCodexWakeTarget, pruneDeadCodexWakeTargets } from './cubes.js';
26
28
  import { monitorStateRootForWorktree } from './inbox-monitor.js';
@@ -81,6 +83,23 @@ import { connectOpenCodeDrone, computeOpenCodePort, createOpenCodeLaunchKickoff,
81
83
  import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
82
84
  import { runEarlyServerFacade } from './server-facade.js';
83
85
 
86
+ export type AssimilateDepsBuilder = typeof buildDefaultAssimilateDeps;
87
+
88
+ export async function runAssimilateEntry(
89
+ args: readonly string[],
90
+ buildDeps: AssimilateDepsBuilder = buildDefaultAssimilateDeps,
91
+ ): Promise<number> {
92
+ const parsed = parseAssimilateArgs([...args]);
93
+ if (!parsed.ok) {
94
+ process.stderr.write(
95
+ chalk.red(`${consolePrefix()}◼ borg assimilate: ${parsed.error}\n`)
96
+ );
97
+ process.stderr.write(`Run \`borg --help\` for usage.\n`);
98
+ return 1;
99
+ }
100
+ return runAssimilate({ role: parsed.role, flags: parsed.flags }, buildDeps());
101
+ }
102
+
84
103
  async function main() {
85
104
  const serverExitCode = await runEarlyServerFacade(process.argv);
86
105
  if (serverExitCode !== null) process.exit(serverExitCode);
@@ -128,16 +147,7 @@ async function main() {
128
147
  process.stdout.write(assimilateHelpText(getPackageVersion()));
129
148
  process.exit(0);
130
149
  }
131
- const parsed = parseAssimilateArgs(process.argv.slice(3));
132
- if (!parsed.ok) {
133
- process.stderr.write(
134
- chalk.red(`${consolePrefix()}◼ borg assimilate: ${parsed.error}\n`)
135
- );
136
- process.stderr.write(`Run \`borg --help\` for usage.\n`);
137
- process.exit(1);
138
- }
139
- const deps = buildDefaultAssimilateDeps();
140
- const code = await runAssimilate({ role: parsed.role, flags: parsed.flags }, deps);
150
+ const code = await runAssimilateEntry(process.argv.slice(3));
141
151
  process.exit(code);
142
152
  }
143
153
  if (process.argv[2] === 'reset-local-seat') {
@@ -560,7 +570,17 @@ function ensureDetectedCliConfigured(): void {
560
570
  }
561
571
  }
562
572
 
563
- main().catch((error) => {
564
- console.error(`${consolePrefix()}${chalk.red(`\n◼ Error: ${error.message}\n`)}`);
565
- process.exit(1);
566
- });
573
+ function isEntryInvocation(): boolean {
574
+ try {
575
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+
581
+ if (isEntryInvocation()) {
582
+ main().catch((error) => {
583
+ console.error(`${consolePrefix()}${chalk.red(`\n◼ Error: ${error.message}\n`)}`);
584
+ process.exit(1);
585
+ });
586
+ }
package/src/cli-help.ts CHANGED
@@ -30,6 +30,7 @@ export function topLevelHelpText(version: string): string {
30
30
  ` borg assimilate [role] Join or create a cube\n` +
31
31
  ` borg assimilate --host <host> Join or create on an explicit server\n` +
32
32
  ` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
33
+ ` borg server cube init Initialize this repository's cube without creating a drone\n` +
33
34
  ` borg reset-local-seat Clear ONLY this worktree's saved local seat (offline; after a rejection)\n` +
34
35
  ` borg sync [--prune] Sync this worktree's branch to origin/main\n` +
35
36
  ` borg cleanup [--prune] Report (or --prune) worktrees orphaned by evicted drones\n` +
@@ -53,11 +54,28 @@ export function serverHelpText(): string {
53
54
  ` stop Stop the managed local server.\n` +
54
55
  ` status Report verified runtime evidence.\n` +
55
56
  ` update Verify and activate a local server artifact.\n` +
56
- ` invite Create a single-use invitation in an interactive terminal.\n\n` +
57
+ ` invite Create a single-use invitation in an interactive terminal.\n` +
58
+ ` cube init Initialize this Git repository's cube; does not create a drone.\n\n` +
57
59
  `Run borg server <command> --help for server command options.\n`
58
60
  );
59
61
  }
60
62
 
63
+ /** Client-owned help for repository cube initialization without a drone. */
64
+ export function cubeInitHelpText(): string {
65
+ return (
66
+ `borg server cube init — initialize this Git repository's cube without creating a drone\n\n` +
67
+ `Usage:\n` +
68
+ ` borg server cube init [options]\n\n` +
69
+ `Options:\n` +
70
+ ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
71
+ ` --enroll Prompt for a hidden enrollment invitation\n` +
72
+ ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
73
+ ` --template software-dev|starter New-cube template (default: software-dev)\n` +
74
+ ` --yes, -y Skip confirmation prompts\n` +
75
+ ` --help, -h Show this help\n`
76
+ );
77
+ }
78
+
61
79
  /**
62
80
  * Help text for `borg assimilate --help` — the home for the full assimilate flag
63
81
  * set. Model/provider configuration belongs to the selected agent CLI.
@@ -76,15 +94,17 @@ export function assimilateHelpText(version: string): string {
76
94
  `Flags:\n` +
77
95
  ` --worktree <name> Create + launch the drone in a sibling git worktree\n` +
78
96
  ` --here Stay in the current worktree (no sibling spawn)\n` +
79
- ` --cube-name <name> Cube to join/create (otherwise confirm repo basename)\n` +
97
+ ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
80
98
  ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
81
99
  ` --enroll Prompt for a hidden enrollment invitation in the operator terminal\n` +
82
- ` --template <name> Bootstrap a new cube from a bundled role template\n` +
83
- ` --no-template Create the cube with no template roles\n` +
100
+ ` --template software-dev|starter New-cube template (default: software-dev)\n` +
101
+ ` --no-template Unsupported for repository cube creation\n` +
84
102
  ` --cli claude|codex|opencode Agent CLI to launch (default: claude)\n` +
85
103
  ` --model claude:<model> Legacy Claude model override (configure models in the agent CLI)\n` +
86
104
  ` --yes, -y Skip confirmation prompts\n\n` +
87
- `An enrolled owner client may create an idempotent repository cube; ordinary clients\n` +
105
+ `Creation shows repository context, name, template, and one confirmation. An existing\n` +
106
+ `repository association skips all prompts. An enrolled owner client may create an\n` +
107
+ `idempotent repository cube; ordinary clients\n` +
88
108
  `require an explicit cube grant. Agent seats begin only after enrollment. Preview only.\n` +
89
109
  `See docs/LOCAL_SERVER.md for self-hosted setup and current status.\n\n` +
90
110
  `For local or provider-specific models, configure the selected agent CLI directly.\n` +