@tablation/crew 0.32.0 → 0.33.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 (2) hide show
  1. package/dist/cli.js +154 -16
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { resolve as resolve4, dirname as dirname12, join as join21, basename as basename9 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { hostname as hostname2, homedir as homedir6 } from "os";
7
- import { spawnSync as spawnSync3 } from "child_process";
7
+ import { spawnSync as spawnSync4 } from "child_process";
8
8
 
9
9
  // src/config.ts
10
10
  import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
@@ -159,6 +159,7 @@ var eventFileFor = (stateDir) => join(stateDir, "events.jsonl");
159
159
 
160
160
  // src/pair-install.ts
161
161
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync } from "fs";
162
+ import { spawnSync as spawnSync2 } from "child_process";
162
163
  import { homedir as homedir2 } from "os";
163
164
  import { dirname as dirname2, join as join3 } from "path";
164
165
 
@@ -668,10 +669,144 @@ var geminiPair = {
668
669
  return hadBlock || hadMcp ? `pair(gemini): ${o.dryRun ? "(dry run) would remove" : "removed"} ${what}${kept}` : `pair(gemini): nothing to remove${kept}`;
669
670
  }
670
671
  };
672
+ var VSCODE_FLAVOURS = ["Code", "Code - Insiders"];
673
+ function vscodeUserDirs(o) {
674
+ const home = o.home ?? homedir2();
675
+ const platform2 = o.platform ?? process.platform;
676
+ const root = platform2 === "darwin" ? join3(home, "Library", "Application Support") : platform2 === "win32" ? process.env.APPDATA ?? join3(home, "AppData", "Roaming") : join3(home, ".config");
677
+ const all = VSCODE_FLAVOURS.map((f) => join3(root, f, "User"));
678
+ const present = all.filter((d) => existsSync2(d));
679
+ return present.length > 0 ? present : [all[0]];
680
+ }
681
+ var COPILOT_FRONTMATTER = '---\napplyTo: "**"\n---\n\n';
682
+ function readVscodeMcp(path) {
683
+ const text = readIfExists(path);
684
+ if (text === void 0 || text.trim() === "") return {};
685
+ try {
686
+ const j = JSON.parse(text);
687
+ return j && typeof j === "object" && !Array.isArray(j) ? j : void 0;
688
+ } catch {
689
+ return void 0;
690
+ }
691
+ }
692
+ var COPILOT_AUTH = `Bearer \${env:${PAIR_KEY_VAR}}`;
693
+ var copilotEntry = (url) => ({ type: "http", url, headers: { Authorization: COPILOT_AUTH } });
694
+ function copilotMcpState(s, url) {
695
+ const e = s.servers?.[SERVER];
696
+ if (!e) return { state: "missing" };
697
+ if (e.url !== url) return { state: "different", detail: `it points at ${e.url ?? "(no url)"}, not ${url}` };
698
+ if (e.headers?.Authorization !== COPILOT_AUTH) return { state: "different", detail: `it does not read its key from \${env:${PAIR_KEY_VAR}}` };
699
+ return { state: "registered" };
700
+ }
701
+ function installCopilotInstructions(path, dryRun) {
702
+ const before = readIfExists(path);
703
+ const base = before ?? COPILOT_FRONTMATTER;
704
+ if (pairBlockIsBroken(base)) return { changed: false, error: `${path} has an unmatched ${PAIR_BLOCK_START}/${PAIR_BLOCK_END} marker \u2014 fix it by hand` };
705
+ const after = upsertPairBlock(base, pairBlock("copilot"));
706
+ if (after === before) return { changed: false };
707
+ if (!dryRun) writeFileMkdir(path, after);
708
+ return { changed: true };
709
+ }
710
+ function removeCopilotInstructions(path, dryRun) {
711
+ const before = readIfExists(path);
712
+ if (before === void 0 || !hasPairBlock(before)) return false;
713
+ if (dryRun) return true;
714
+ const after = removePairBlock(before);
715
+ if (after.trim() === "" || after.trim() === COPILOT_FRONTMATTER.trim()) rmSync2(path);
716
+ else writeFileSync(path, after);
717
+ return true;
718
+ }
719
+ var copilotPair = {
720
+ harness: "copilot",
721
+ displayName: "GitHub Copilot (VS Code)",
722
+ installPair(o) {
723
+ const lines = [];
724
+ for (const dir of vscodeUserDirs(o)) {
725
+ const mcpPath = join3(dir, "mcp.json");
726
+ const instrPath = join3(dir, "prompts", "crew-pair.instructions.md");
727
+ const settings = readVscodeMcp(mcpPath);
728
+ if (!settings) {
729
+ lines.push(`pair(copilot): ${mcpPath} is not a plain JSON object (comments are not supported) \u2014 fix it by hand; nothing was written there`);
730
+ continue;
731
+ }
732
+ const st = copilotMcpState(settings, o.url);
733
+ if (st.state === "different" && !o.replace) {
734
+ lines.push(`pair(copilot): left ${mcpPath} alone \u2014 a "${SERVER}" server exists and ${st.detail}. Re-run with --mcp replace to overwrite it.`);
735
+ continue;
736
+ }
737
+ const instr = installCopilotInstructions(instrPath, o.dryRun);
738
+ if (instr.error) {
739
+ lines.push(`pair(copilot): ${instr.error}`);
740
+ continue;
741
+ }
742
+ const mcpChanged = st.state !== "registered";
743
+ if (mcpChanged && !o.dryRun) {
744
+ const next = { ...settings, servers: { ...settings.servers, [SERVER]: copilotEntry(o.url) } };
745
+ writeFileMkdir(mcpPath, `${JSON.stringify(next, null, 2)}
746
+ `);
747
+ }
748
+ lines.push(!instr.changed && !mcpChanged ? `pair(copilot): already installed (${instrPath}, ${mcpPath})` : `pair(copilot): ${o.dryRun ? "(dry run) would install" : "installed"} the Pair instructions in ${instrPath} and ${SERVER} in ${mcpPath} (key read from \${env:${PAIR_KEY_VAR}})`);
749
+ }
750
+ return lines.join("\n");
751
+ },
752
+ verifyPair(o) {
753
+ const verdicts = vscodeUserDirs(o).map((dir) => {
754
+ const instrPath = join3(dir, "prompts", "crew-pair.instructions.md");
755
+ const mcpPath = join3(dir, "mcp.json");
756
+ const b = blockVerdict(instrPath, "copilot");
757
+ if (!b.ok) return b;
758
+ const settings = readVscodeMcp(mcpPath);
759
+ if (!settings) return { ok: false, detail: `${mcpPath} is not a plain JSON object` };
760
+ const st = copilotMcpState(settings, o.url);
761
+ if (st.state === "missing") return { ok: false, detail: `no ${SERVER} server in ${mcpPath}` };
762
+ if (st.state === "different") return { ok: false, detail: `${SERVER} in ${mcpPath} ${st.detail}` };
763
+ return { ok: true, detail: `${instrPath}, ${mcpPath}` };
764
+ });
765
+ return verdicts.find((v) => !v.ok) ?? { ok: true, detail: verdicts.map((v) => v.detail).join("; ") };
766
+ },
767
+ removePair(o) {
768
+ const lines = [];
769
+ for (const dir of vscodeUserDirs(o)) {
770
+ const mcpPath = join3(dir, "mcp.json");
771
+ const hadBlock = removeCopilotInstructions(join3(dir, "prompts", "crew-pair.instructions.md"), o.dryRun);
772
+ const settings = readVscodeMcp(mcpPath);
773
+ const st = settings ? copilotMcpState(settings, o.url) : { state: "missing" };
774
+ const hadMcp = st.state === "registered";
775
+ const kept = st.state === "different" ? `; left the existing ${SERVER} server alone \u2014 ${st.detail}` : "";
776
+ if (hadMcp && settings && !o.dryRun) {
777
+ const { [SERVER]: _gone, ...rest } = settings.servers ?? {};
778
+ const next = { ...settings };
779
+ if (Object.keys(rest).length > 0) next.servers = rest;
780
+ else delete next.servers;
781
+ if (Object.keys(next).length === 0) rmSync2(mcpPath);
782
+ else writeFileSync(mcpPath, `${JSON.stringify(next, null, 2)}
783
+ `);
784
+ }
785
+ const what = hadBlock && hadMcp ? `the Pair instructions and ${SERVER} server` : hadBlock ? "the Pair instructions" : `the ${SERVER} server`;
786
+ lines.push(hadBlock || hadMcp ? `pair(copilot): ${o.dryRun ? "(dry run) would remove" : "removed"} ${what} (${dir})${kept}` : `pair(copilot): nothing to remove in ${dir}${kept}`);
787
+ }
788
+ return lines.join("\n");
789
+ },
790
+ /**
791
+ * VS Code launched from the Dock or Spotlight does not inherit a shell's
792
+ * exports, so `${env:CREW_API_KEY}` expands to nothing there. macOS only: the
793
+ * login environment is `launchctl`'s, and it is the one place we can ask.
794
+ */
795
+ envWarning(o) {
796
+ if ((o.platform ?? process.platform) !== "darwin") return void 0;
797
+ const get2 = o.loginEnv ?? ((name) => {
798
+ const r = spawnSync2("launchctl", ["getenv", name], { encoding: "utf8" });
799
+ return r.status === 0 ? r.stdout.trim() : void 0;
800
+ });
801
+ if (get2(PAIR_KEY_VAR)) return void 0;
802
+ return `$${PAIR_KEY_VAR} is not in the login environment, so VS Code started from the Dock will not send the tracker key. Launch VS Code from a terminal (\`code .\`), or run \`launchctl setenv ${PAIR_KEY_VAR} <key>\` once per login.`;
803
+ }
804
+ };
671
805
  var PAIR_INSTALLERS = {
672
806
  [claudePair.harness]: claudePair,
673
807
  [codexPair.harness]: codexPair,
674
- [geminiPair.harness]: geminiPair
808
+ [geminiPair.harness]: geminiPair,
809
+ [copilotPair.harness]: copilotPair
675
810
  };
676
811
  var PAIR_HARNESS_NAMES = Object.keys(PAIR_INSTALLERS);
677
812
  var DEFAULT_PAIR = ["claude"];
@@ -2908,7 +3043,7 @@ function describeRelease(o, route) {
2908
3043
  import { dirname as dirname4, join as join7, extname } from "path";
2909
3044
  import { homedir as homedir4 } from "os";
2910
3045
  import { accessSync, constants, existsSync as existsSync6, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync7, statSync as statSync3, writeFileSync as writeFileSync3, unlinkSync, realpathSync } from "fs";
2911
- import { spawnSync as spawnSync2 } from "child_process";
3046
+ import { spawnSync as spawnSync3 } from "child_process";
2912
3047
  import { createHash } from "crypto";
2913
3048
 
2914
3049
  // src/runtime-info.ts
@@ -3199,7 +3334,7 @@ function planDaemonControl(crewHome, hostShip, hasSystemd) {
3199
3334
  }
3200
3335
  function runAll(commands, cwd, log) {
3201
3336
  for (const [bin, ...args] of commands) {
3202
- const res = spawnSync2(bin, args, { cwd, encoding: "utf8" });
3337
+ const res = spawnSync3(bin, args, { cwd, encoding: "utf8" });
3203
3338
  if (res.error || res.status !== 0) {
3204
3339
  log.warn(`${bin} ${args.join(" ")} exited ${res.status ?? "error"}`, {
3205
3340
  output: `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? String(res.error) : ""}`
@@ -3264,7 +3399,7 @@ async function applyUninstall(plan, crewHome, dryRun, log) {
3264
3399
  if (dryRun) {
3265
3400
  log.emit(`would run: launchctl remove ${plan.removeLabel}`);
3266
3401
  } else {
3267
- if (spawnSync2("launchctl", ["remove", plan.removeLabel], { encoding: "utf8" }).status === 0) {
3402
+ if (spawnSync3("launchctl", ["remove", plan.removeLabel], { encoding: "utf8" }).status === 0) {
3268
3403
  log.emit(`removed loaded job ${plan.removeLabel}`);
3269
3404
  }
3270
3405
  }
@@ -3301,7 +3436,7 @@ function programFromPlist(text) {
3301
3436
  return args.find((a) => a.endsWith("/bin/crew")) ?? args[0] ?? null;
3302
3437
  }
3303
3438
  function loadedCrewLabels() {
3304
- const list = spawnSync2("launchctl", ["list"], { encoding: "utf8" });
3439
+ const list = spawnSync3("launchctl", ["list"], { encoding: "utf8" });
3305
3440
  if (list.status !== 0) return [];
3306
3441
  return [...parseLaunchctlList(list.stdout ?? "").keys()].filter((l) => l.startsWith(CREW_LAUNCHD_PREFIX));
3307
3442
  }
@@ -3337,8 +3472,8 @@ function removeLaunchdUnit(unit, dryRun, log) {
3337
3472
  log.emit(`would unload and remove ${unit.label}`);
3338
3473
  return;
3339
3474
  }
3340
- if (existsSync6(unit.plistPath)) spawnSync2("launchctl", ["unload", unit.plistPath], { encoding: "utf8" });
3341
- spawnSync2("launchctl", ["remove", unit.label], { encoding: "utf8" });
3475
+ if (existsSync6(unit.plistPath)) spawnSync3("launchctl", ["unload", unit.plistPath], { encoding: "utf8" });
3476
+ spawnSync3("launchctl", ["remove", unit.label], { encoding: "utf8" });
3342
3477
  if (existsSync6(unit.plistPath)) unlinkSync(unit.plistPath);
3343
3478
  log.emit(`removed ${unit.label} (${unit.plistPath})`);
3344
3479
  }
@@ -3371,7 +3506,7 @@ function parseLaunchctlList(text) {
3371
3506
  return jobs;
3372
3507
  }
3373
3508
  function describeForeignLaunchdUnits(crewHome, dir) {
3374
- const list = spawnSync2("launchctl", ["list"], { encoding: "utf8" });
3509
+ const list = spawnSync3("launchctl", ["list"], { encoding: "utf8" });
3375
3510
  const jobs = parseLaunchctlList(list.status === 0 ? list.stdout ?? "" : "");
3376
3511
  const foreign = foreignLaunchdUnits(crewHome, dir, jobs.keys());
3377
3512
  return foreign.map((u) => {
@@ -3384,11 +3519,11 @@ function describeForeignLaunchdUnits(crewHome, dir) {
3384
3519
  });
3385
3520
  }
3386
3521
  function readCrontab() {
3387
- const res = spawnSync2("crontab", ["-l"], { encoding: "utf8" });
3522
+ const res = spawnSync3("crontab", ["-l"], { encoding: "utf8" });
3388
3523
  return res.status === 0 ? res.stdout ?? "" : "";
3389
3524
  }
3390
3525
  function writeCrontab(text) {
3391
- spawnSync2("crontab", ["-"], { input: text, encoding: "utf8" });
3526
+ spawnSync3("crontab", ["-"], { input: text, encoding: "utf8" });
3392
3527
  }
3393
3528
  function addCrontabLine(marker, line) {
3394
3529
  const existing = readCrontab();
@@ -3412,7 +3547,7 @@ function removeCrontabLine(marker) {
3412
3547
  }
3413
3548
  function detectSystemd() {
3414
3549
  if (process.platform !== "linux") return false;
3415
- const res = spawnSync2("systemctl", ["--user", "status"], { encoding: "utf8" });
3550
+ const res = spawnSync3("systemctl", ["--user", "status"], { encoding: "utf8" });
3416
3551
  return !res.error;
3417
3552
  }
3418
3553
 
@@ -11734,7 +11869,7 @@ if (command === "passenger-sync-daemon") {
11734
11869
  `
11735
11870
  );
11736
11871
  if (!dryRun) {
11737
- spawnSync3("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
11872
+ spawnSync4("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
11738
11873
  try {
11739
11874
  unlinkSync5(staleTimer);
11740
11875
  } catch {
@@ -11773,7 +11908,7 @@ if (command === "passenger-sync-daemon") {
11773
11908
  async function doctorFix(o) {
11774
11909
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
11775
11910
  const unitExists = (p) => existsSync21(p);
11776
- const crontab = o.host === "linux" ? spawnSync3("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
11911
+ const crontab = o.host === "linux" ? spawnSync4("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
11777
11912
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
11778
11913
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
11779
11914
  for (const n of plan.notes) process.stdout.write(`fix: ${n}
@@ -12645,7 +12780,7 @@ ${changed2.map((c) => ` - ${c}
12645
12780
  }
12646
12781
  let failed = false;
12647
12782
  for (const cmd2 of commands) {
12648
- const res2 = spawnSync3(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
12783
+ const res2 = spawnSync4(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
12649
12784
  if (res2.stdout) process.stdout.write(res2.stdout);
12650
12785
  if (res2.stderr) process.stderr.write(res2.stderr);
12651
12786
  if (cmd2 === control.startCommand && (res2.error || res2.status !== 0)) {
@@ -12663,7 +12798,7 @@ ${changed2.map((c) => ` - ${c}
12663
12798
  `);
12664
12799
  break;
12665
12800
  }
12666
- const res = spawnSync3(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
12801
+ const res = spawnSync4(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
12667
12802
  if (res.stdout) process.stdout.write(res.stdout);
12668
12803
  if (res.stderr) process.stderr.write(res.stderr);
12669
12804
  if (daemonSub !== "status" && (res.error || res.status !== 0)) {
@@ -14242,6 +14377,9 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
14242
14377
  process.stdout.write(`pair ${h}:`.padEnd(19) + (v.ok ? ` installed (${v.detail})
14243
14378
  ` : ` NOT installed \u2014 ${v.detail}; run crew connect
14244
14379
  `));
14380
+ const envWarn = inst.envWarning?.({ url: mcpUrlFor(route.baseUrl) });
14381
+ if (envWarn) process.stdout.write(`pair ${h} env:`.padEnd(19) + ` WARN \u2014 ${envWarn}
14382
+ `);
14245
14383
  }
14246
14384
  const shipVendors = new Set(shipToolchains(cfg.ship).map(toolchainVendor));
14247
14385
  if (route.apiKey && route.resolved) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",