heyspex 0.2.2 → 0.2.3

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 (70) hide show
  1. package/README.md +10 -0
  2. package/bin/hs-vault-binding.mjs +157 -0
  3. package/bin/hs.mjs +81 -77
  4. package/bin/spexcode.mjs +91 -0
  5. package/dev-factory/engine/worktree.mjs +36 -7
  6. package/dev-factory/lib/epic-detect.mjs +2 -2
  7. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  8. package/dev-factory/lib/fleet-state.mjs +2 -2
  9. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  10. package/dist/cli/hs-check.mjs +76 -46
  11. package/dist/cli/hs-claim.mjs +63 -33
  12. package/dist/cli/hs-connect.mjs +520 -214
  13. package/dist/cli/hs-defer.mjs +161 -19
  14. package/dist/cli/hs-dev.mjs +11 -5
  15. package/dist/cli/hs-env.mjs +187 -161
  16. package/dist/cli/hs-index-rebuild.mjs +123 -78
  17. package/dist/cli/hs-index-reconcile.mjs +203 -107
  18. package/dist/cli/hs-init.mjs +426 -179
  19. package/dist/cli/hs-install.mjs +759 -437
  20. package/dist/cli/hs-join.mjs +590 -383
  21. package/dist/cli/hs-vault-pull.mjs +359 -254
  22. package/dist/cli/hs-vault-push.mjs +151 -113
  23. package/dist/cli/hs-vault.mjs +271 -104
  24. package/dist/cli/hs-workspace.mjs +90 -57
  25. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  26. package/docs/copilot-plugin-install.md +189 -177
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -250
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -312
  32. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  33. package/plugins/heyspex/mcp.json +12 -13
  34. package/scripts/check-mcp-freshness.mjs +6 -4
  35. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  36. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  37. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  38. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  39. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  40. package/scripts/claude-statusline/statusline-command.mjs +1058 -0
  41. package/scripts/dev-server-status.mjs +3 -2
  42. package/scripts/dev-server-stop.mjs +3 -2
  43. package/scripts/hs-capture-cli.mjs +2 -2
  44. package/scripts/hs-settings.mjs +2 -2
  45. package/scripts/hs-view.mjs +3 -2
  46. package/scripts/install-copilot-hooks.mjs +64 -34
  47. package/scripts/lib/copilot-cli.mjs +108 -0
  48. package/scripts/lib/customer-kit.mjs +223 -30
  49. package/scripts/lib/dev-mode.mjs +28 -2
  50. package/scripts/lib/shared-path-helpers.mjs +32 -0
  51. package/scripts/lib/standalone-doctor.mjs +131 -32
  52. package/scripts/wip-ledger-health.mjs +3 -2
  53. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  54. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  55. package/spexharness/src/intent-directive.mjs +839 -626
  56. package/spexharness/src/rule-table.mjs +1 -1
  57. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  58. package/vendor/heyspex-mcp/cli.mjs +20 -4
  59. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  60. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  61. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  62. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  63. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  64. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  65. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  66. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  67. package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
  68. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  69. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  70. package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
@@ -37,7 +37,8 @@ import fs from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import http from 'node:http';
39
39
  import { spawnSync } from 'node:child_process';
40
- import { fileURLToPath, pathToFileURL } from 'node:url';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { isMainModule } from './lib/shared-path-helpers.mjs';
41
42
 
42
43
  // The runnable form of a HeySpex command on THIS machine — `hs …` when the bin
43
44
  // is on PATH, else an absolute-path `node "<install>/bin/hs.mjs" …`. Never an
@@ -722,7 +723,7 @@ export function summarizeStatus(status) {
722
723
 
723
724
  // ---- CLI entrypoint --------------------------------------------------------
724
725
 
725
- const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
726
+ const invokedDirectly = isMainModule(import.meta.url);
726
727
  if (invokedDirectly) {
727
728
  const asJson = process.argv.includes('--json');
728
729
  getDevServerStatus()
@@ -23,7 +23,8 @@
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { spawnSync } from 'node:child_process';
26
- import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { isMainModule } from './lib/shared-path-helpers.mjs';
27
28
 
28
29
  import { getDevServerStatus, summarizeStatus } from './dev-server-status.mjs';
29
30
 
@@ -276,7 +277,7 @@ export async function stopDevServer({ vaultRoot, forcePorts = false, dryRun = fa
276
277
  };
277
278
  }
278
279
 
279
- const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
280
+ const invokedDirectly = isMainModule(import.meta.url);
280
281
  if (invokedDirectly) {
281
282
  const asJson = process.argv.includes('--json');
282
283
  const dryRun = process.argv.includes('--dry-run');
@@ -22,7 +22,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
22
  import path from "node:path";
23
23
  import process from "node:process";
24
24
  import { randomUUID } from "node:crypto";
25
- import { pathToFileURL } from "node:url";
25
+ import { isMainModule } from "./lib/shared-path-helpers.mjs";
26
26
 
27
27
  import { resolveConsoleUrl, DEFAULT_CONSOLE_URL } from "./lib/console-url-resolver.mjs";
28
28
  import { heyspexHome } from "./lib/heyspex-home.mjs";
@@ -318,7 +318,7 @@ async function main() {
318
318
  // Guard so this module can be `import`ed (e.g. by hs-capture-cli.test.mjs)
319
319
  // without running main() — bin/hs.mjs spawns this file as a child process
320
320
  // with the file path as argv[1], so this only fires under that spawn.
321
- if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
321
+ if (isMainModule(import.meta.url)) {
322
322
  main().catch((err) => {
323
323
  process.stderr.write(`hs capture: ${err.message}\n`);
324
324
  process.exit(1);
@@ -25,7 +25,7 @@
25
25
  import fs from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import process from 'node:process';
28
- import { pathToFileURL } from 'node:url';
28
+ import { isMainModule } from './lib/shared-path-helpers.mjs';
29
29
 
30
30
  import {
31
31
  listConfigLeaves,
@@ -328,7 +328,7 @@ function main(argv) {
328
328
  // helpers (buildListEntries, isHeyspexPlatformVault, …).
329
329
  const isMain = (() => {
330
330
  try {
331
- return import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
331
+ return isMainModule(import.meta.url);
332
332
  } catch {
333
333
  return false;
334
334
  }
@@ -23,7 +23,8 @@
23
23
  */
24
24
  import { spawn } from 'node:child_process';
25
25
  import path from 'node:path';
26
- import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { isMainModule } from './lib/shared-path-helpers.mjs';
27
28
 
28
29
  import { getDevServerStatus } from './dev-server-status.mjs';
29
30
  import { resolveConsoleUrl, DEFAULT_CONSOLE_URL } from './lib/console-url-resolver.mjs';
@@ -488,7 +489,7 @@ async function main() {
488
489
 
489
490
  // Run only as the CLI entry point. Without this guard, importing the module —
490
491
  // which is how `resolveWindowWidth` is tested — would spawn a browser window.
491
- if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
492
+ if (isMainModule(import.meta.url)) {
492
493
  main().catch((err) => {
493
494
  process.stderr.write(`hs view: ${err?.message || err}\n`);
494
495
  process.exitCode = 1;
@@ -16,8 +16,8 @@
16
16
  // Single-source rule: the plugin OR the repo hooks file, never both (every hook would fire twice).
17
17
  //
18
18
  // Exit codes:
19
- // 0 success / nothing to do / `copilot` missing (commands printed for the user to run)
20
- // 1 failure: plugin route could not complete, --check found a problem, unexpected error
19
+ // 0 success / nothing to do
20
+ // 1 failure: no Copilot CLI found (nothing installed), plugin route could not complete, --check found a problem, unexpected error
21
21
  // 2 refused: --hooks while the heyspex plugin is installed for Copilot, or bad arguments
22
22
  //
23
23
  // Never prints a stack trace; every path ends with a next step.
@@ -29,6 +29,7 @@ import { spawnSync } from 'node:child_process';
29
29
  import { fileURLToPath } from 'node:url';
30
30
 
31
31
  import { detectDoubleFire, invokedDirectly, renderGithubHooksText } from './lib/customer-kit.mjs';
32
+ import { COPILOT_PREREQUISITE, copilotHomeDir, parsePluginList, resolveCopilotCli } from './lib/copilot-cli.mjs';
32
33
 
33
34
  export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
35
  export const HOOKS_REL = ['.github', 'hooks', 'heyspex.json'];
@@ -89,35 +90,46 @@ function parseArgs(argv) {
89
90
  return a;
90
91
  }
91
92
 
92
- function copilotRun(args) {
93
+ /**
94
+ * Run the resolved Copilot CLI (PATH first, then the Copilot app's bundled copy, see lib/copilot-cli.mjs).
95
+ * A `.cmd`/`.bat` shim on Windows needs a shell; a real `copilot.exe` is spawned directly.
96
+ */
97
+ function copilotRun(cli, args) {
98
+ if (!cli) return { status: 127, out: '', missing: true };
93
99
  try {
94
- // Windows needs a shell to resolve copilot.cmd; args are constants or the package path, so quote the latter.
95
- const win = process.platform === 'win32';
96
- const argv = win ? args.map((x) => (/[\s&()^]/.test(x) ? `"${x}"` : x)) : args;
97
- const r = spawnSync('copilot', argv, { encoding: 'utf8', shell: win, timeout: 120000 });
100
+ const shim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(cli.path);
101
+ const quote = (x) => (/[\s&()^]/.test(x) ? `"${x}"` : x);
102
+ const r = spawnSync(shim ? quote(cli.path) : cli.path, shim ? args.map(quote) : args, { encoding: 'utf8', shell: shim, timeout: 120000, env: process.env });
98
103
  return { status: r.error ? 127 : (r.status ?? 1), out: `${r.stdout ?? ''}${r.stderr ?? ''}`, missing: !!r.error };
99
104
  } catch {
100
105
  return { status: 127, out: '', missing: true };
101
106
  }
102
107
  }
103
108
 
104
- function copilotOnPath() {
105
- const r = copilotRun(['--version']);
106
- return !r.missing && r.status === 0;
109
+ /** Resolve the CLI once and confirm it runs; returns { path, source, version? } or null. */
110
+ function locateCopilot() {
111
+ const cli = resolveCopilotCli();
112
+ if (!cli) return null;
113
+ const r = copilotRun(cli, ['--version']);
114
+ return !r.missing && r.status === 0 ? cli : null;
107
115
  }
108
116
 
117
+ const describeCli = (cli) => (cli ? `${cli.path} (${cli.source === 'app' ? 'Copilot app' : 'PATH'})` : 'not found');
118
+
109
119
  const q = (s) => `'${s.replace(/'/g, "''")}'`;
110
120
  const nextCommand = () => 'npx heyspex setup copilot';
111
121
 
112
122
  /** Is the heyspex plugin installed for Copilot? Combines detectDoubleFire evidence with `copilot plugin list`. */
113
- function pluginEvidence(targetRoot, copilotAvailable) {
114
- const df = detectDoubleFire({ targetRoot, home: os.homedir(), deps: fsDeps });
123
+ function pluginEvidence(targetRoot, cli) {
124
+ const copilotHome = copilotHomeDir();
125
+ const df = detectDoubleFire({ targetRoot, home: os.homedir(), copilotHome, deps: fsDeps });
115
126
  // Only Copilot-side evidence counts: a Claude Code plugin install (~/.claude) is not a Copilot install.
116
- const evidence = df.plugin === true ? df.evidence.filter((e) => /[/\\]\.copilot[/\\]/.test(e)) : [];
127
+ const evidence = df.plugin === true ? df.evidence.filter((e) => e.includes(copilotHome)) : [];
117
128
  let plugin = evidence.length > 0;
118
- if (copilotAvailable) {
119
- const r = copilotRun(['plugin', 'list']);
120
- if (r.status === 0 && /heyspex/i.test(r.out)) {
129
+ if (cli) {
130
+ const r = copilotRun(cli, ['plugin', 'list']);
131
+ const listed = parsePluginList(r.out);
132
+ if (r.status === 0 && listed.enabled) {
121
133
  plugin = true;
122
134
  evidence.push('`copilot plugin list` names heyspex');
123
135
  }
@@ -161,8 +173,8 @@ function doPrint(out) {
161
173
 
162
174
  function doCheck(a, target, out) {
163
175
  const spec = resolvePluginSpec(PACKAGE_ROOT);
164
- const hasCopilot = copilotOnPath();
165
- const ev = pluginEvidence(target, hasCopilot);
176
+ const cli = locateCopilot();
177
+ const ev = pluginEvidence(target, cli);
166
178
  const hooks = hooksFileState(target);
167
179
  const route = ev.plugin && hooks.exists ? 'both' : ev.plugin ? 'plugin' : hooks.exists ? 'hooks' : 'none';
168
180
  const problems = [];
@@ -171,7 +183,8 @@ function doCheck(a, target, out) {
171
183
  if (route === 'hooks' && !hooks.ours) problems.push('.github/hooks/heyspex.json is not a HeySpex file');
172
184
  if (route === 'hooks' && hooks.ours && !hooks.current) problems.push('.github/hooks/heyspex.json is stale; re-run `npx heyspex setup copilot --hooks`');
173
185
  if (route === 'none') problems.push(`no route active; run \`${nextCommand()}\` (plugin) or \`${nextCommand()} --hooks\``);
174
- const report = { route, plugin: ev.plugin, pluginEvidence: ev.evidence, hooksFile: hooks.exists, hooksFilePath: hooks.file, hooksCurrent: hooks.current, marketplaceManifest: spec.present, marketplacePath: spec.manifest, copilotOnPath: hasCopilot, problems };
186
+ if (!cli && route === 'none') problems.push(`Copilot CLI not found (not on PATH, no Copilot app copy). ${COPILOT_PREREQUISITE}`);
187
+ const report = { route, plugin: ev.plugin, pluginEvidence: ev.evidence, hooksFile: hooks.exists, hooksFilePath: hooks.file, hooksCurrent: hooks.current, marketplaceManifest: spec.present, marketplacePath: spec.manifest, copilotCli: cli, copilotHome: copilotHomeDir(), problems };
175
188
  if (a.json) out(JSON.stringify(report, null, 2));
176
189
  else {
177
190
  out(`hs setup copilot --check (target ${target})`);
@@ -179,7 +192,8 @@ function doCheck(a, target, out) {
179
192
  out(` plugin installed: ${ev.plugin ? 'yes' : 'no'}${ev.evidence.length ? ` (${ev.evidence.join('; ')})` : ''}`);
180
193
  out(` hooks file: ${hooks.exists ? `present${hooks.ours ? '' : ' (not HeySpex)'}${hooks.current ? ', current' : ', stale'}` : 'absent'}`);
181
194
  out(` marketplace manifest in package: ${spec.present ? 'present' : 'MISSING'}`);
182
- out(` copilot CLI on PATH: ${hasCopilot ? 'yes' : 'no'}`);
195
+ out(` copilot CLI: ${describeCli(cli)}`);
196
+ if (process.env.COPILOT_HOME) out(` COPILOT_HOME: ${copilotHomeDir()}`);
183
197
  for (const p of problems) out(` PROBLEM: ${p}`);
184
198
  if (!problems.length) out(' ok');
185
199
  }
@@ -187,7 +201,7 @@ function doCheck(a, target, out) {
187
201
  }
188
202
 
189
203
  function doHooks(target, out, err) {
190
- const ev = pluginEvidence(target, copilotOnPath());
204
+ const ev = pluginEvidence(target, locateCopilot());
191
205
  if (ev.plugin) {
192
206
  err(`hs setup copilot --hooks refused: the heyspex plugin is already installed for Copilot (${ev.evidence.join('; ')}).`);
193
207
  err('Plugin OR hooks, never both (every hook would fire twice). Remove the plugin first: `copilot plugin uninstall heyspex`.');
@@ -222,20 +236,23 @@ function doPlugin(target, out, err) {
222
236
  out(`WARNING: ${hooks.file} already exists. Plugin OR hooks, never both (every hook would fire twice). Remove it with \`npx heyspex setup copilot --uninstall\` after the plugin is installed.`);
223
237
  }
224
238
  const cmds = manualCommands(spec.spec);
225
- if (!copilotOnPath()) {
226
- out('`copilot` is not on PATH. Install GitHub Copilot CLI, then run these two commands:');
227
- for (const c of cmds) out(` ${c}`);
228
- out('Then /restart in Copilot and verify with `copilot plugin list` and `npx heyspex doctor`.');
229
- return 0;
239
+ const cli = locateCopilot();
240
+ if (!cli) {
241
+ err('hs setup copilot: GitHub Copilot CLI not found (not on PATH, and no Copilot app copy under %LOCALAPPDATA%\\github-copilot-sdk\\cli). Nothing was installed.');
242
+ err(COPILOT_PREREQUISITE);
243
+ err('Then re-run `npx heyspex setup copilot`, or run these two commands by hand:');
244
+ for (const c of cmds) err(` ${c}`);
245
+ return 1;
230
246
  }
231
- const add = copilotRun(['plugin', 'marketplace', 'add', PACKAGE_ROOT]);
247
+ out(`using Copilot CLI: ${describeCli(cli)}`);
248
+ const add = copilotRun(cli, ['plugin', 'marketplace', 'add', PACKAGE_ROOT]);
232
249
  if (add.status !== 0 && !/already/i.test(add.out)) {
233
250
  err(`copilot plugin marketplace add failed:\n${add.out.trim().slice(-600)}`);
234
251
  err('Run these by hand and report the output:');
235
252
  for (const c of cmds) err(` ${c}`);
236
253
  return 1;
237
254
  }
238
- const inst = copilotRun(['plugin', 'install', spec.spec]);
255
+ const inst = copilotRun(cli, ['plugin', 'install', spec.spec]);
239
256
  if (inst.status !== 0 && !/already/i.test(inst.out)) {
240
257
  err(`copilot plugin install failed:\n${inst.out.trim().slice(-600)}`);
241
258
  err('Run this by hand and report the output:');
@@ -247,16 +264,29 @@ function doPlugin(target, out, err) {
247
264
  return 0;
248
265
  }
249
266
 
250
- function doUninstall(target, out) {
267
+ function doUninstall(target, out, err) {
251
268
  const hooks = hooksFileState(target);
252
269
  if (!hooks.exists) out(`no hooks file at ${hooks.file}`);
253
270
  else if (!hooks.ours) out(`left ${hooks.file} alone: it is not a HeySpex-only file`);
254
271
  else { fs.rmSync(hooks.file, { force: true }); out(`removed ${hooks.file}`); }
255
272
  const cmd = 'copilot plugin uninstall heyspex';
256
- if (copilotOnPath()) {
257
- const r = copilotRun(['plugin', 'uninstall', 'heyspex']);
258
- out(r.status === 0 ? `ran \`${cmd}\`` : `\`${cmd}\` did not complete (it may not be installed): ${r.out.trim().slice(-200)}`);
259
- } else out(`To remove the plugin, run: ${cmd}`);
273
+ const cli = locateCopilot();
274
+ if (cli) {
275
+ const r = copilotRun(cli, ['plugin', 'uninstall', 'heyspex']);
276
+ out(r.status === 0 ? `ran \`${cmd}\` via ${describeCli(cli)}` : `\`${cmd}\` did not complete (it may not be installed): ${r.out.trim().slice(-200)}`);
277
+ // A plugin loaded from a local marketplace is only DISABLED by `plugin uninstall` (it stays
278
+ // in `plugin list`), so drop the marketplace we registered too.
279
+ const mk = copilotRun(cli, ['plugin', 'marketplace', 'remove', resolvePluginSpec(PACKAGE_ROOT).marketplace]);
280
+ if (mk.status === 0) out(`removed marketplace ${resolvePluginSpec(PACKAGE_ROOT).marketplace}`);
281
+ return 0;
282
+ }
283
+ // No CLI: only an error when a plugin install is actually on record and could not be removed.
284
+ if (pluginEvidence(target, null).plugin) {
285
+ err(`hs setup copilot --uninstall: the heyspex plugin is installed for Copilot but no Copilot CLI was found to remove it. ${COPILOT_PREREQUISITE}`);
286
+ err(`Then run: ${cmd}`);
287
+ return 1;
288
+ }
289
+ out(`no heyspex plugin found for Copilot; nothing more to remove (to be sure, run: ${cmd})`);
260
290
  return 0;
261
291
  }
262
292
 
@@ -273,7 +303,7 @@ export function main(argv = process.argv.slice(2), io = {}) {
273
303
  if (a.print) return doPrint(out);
274
304
  const target = a.target ? path.resolve(a.target) : findRepoRoot(process.env.HS_SETUP_TARGET?.trim() || process.cwd());
275
305
  if (a.check) return doCheck(a, target, out);
276
- if (a.uninstall) return doUninstall(target, out);
306
+ if (a.uninstall) return doUninstall(target, out, err);
277
307
  if (a.hooks) return doHooks(target, out, err);
278
308
  return doPlugin(target, out, err);
279
309
  } catch (e) {
@@ -0,0 +1,108 @@
1
+ // copilot-cli.mjs - find the GitHub Copilot CLI and its settings dir (HS-GLR5).
2
+ //
3
+ // `copilot` is not always on PATH: the Copilot Windows app ships the real CLI at
4
+ // %LOCALAPPDATA%\github-copilot-sdk\cli\<version>\copilot.exe (the version folder changes on
5
+ // every app update) and never adds it to PATH. Resolution order:
6
+ // 1. `copilot` on PATH (npm -g @github/copilot, winget, brew ...)
7
+ // 2. Windows only: the NEWEST %LOCALAPPDATA%\github-copilot-sdk\cli\<version>\copilot.exe
8
+ // (macOS / Linux have no confirmed app-bundled location, so PATH is the only route there.)
9
+ //
10
+ // Pure + injectable (exists / listDir / env / platform) so it is unit-testable without a Copilot.
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ /** What a user should install when no Copilot CLI can be found. Shown by setup, --check and the docs. */
17
+ export const COPILOT_PREREQUISITE =
18
+ 'GitHub Copilot CLI is required: install the GitHub Copilot app (auto-detected), or `npm install -g @github/copilot`, or `winget install GitHub.Copilot`.';
19
+
20
+ const realDeps = {
21
+ exists: (p) => {
22
+ try { return fs.statSync(p).isFile(); } catch { return false; }
23
+ },
24
+ listDir: (p) => {
25
+ try { return fs.readdirSync(p); } catch { return []; }
26
+ },
27
+ };
28
+
29
+ /**
30
+ * Compare two Copilot version-folder names like `1.0.99-2` numerically, never lexically
31
+ * (`1.0.100` > `1.0.99`, `1.0.99-10` > `1.0.99-2`). A missing `-N` suffix counts as 0.
32
+ * Returns negative / 0 / positive like Array.sort.
33
+ */
34
+ export function compareCopilotVersions(a, b) {
35
+ const parts = (v) => {
36
+ const [core, ...rest] = String(v).split('-');
37
+ const nums = core.split('.').map((x) => Number.parseInt(x, 10)).map((n) => (Number.isFinite(n) ? n : 0));
38
+ const suffix = Number.parseInt(rest.join('-'), 10);
39
+ return [...nums, Number.isFinite(suffix) ? suffix : 0];
40
+ };
41
+ const pa = parts(a);
42
+ const pb = parts(b);
43
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
44
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
45
+ if (d !== 0) return d;
46
+ }
47
+ return 0;
48
+ }
49
+
50
+ /** First `copilot` executable found on PATH, or null. Windows also tries .exe / .cmd / .bat. */
51
+ export function findCopilotOnPath({ env = process.env, platform = process.platform, exists = realDeps.exists } = {}) {
52
+ const pathVar = env.PATH ?? env.Path ?? env.path ?? '';
53
+ const names = platform === 'win32' ? ['copilot.exe', 'copilot.cmd', 'copilot.bat'] : ['copilot'];
54
+ for (const dir of pathVar.split(path.delimiter).map((d) => d.trim().replace(/^"|"$/g, '')).filter(Boolean)) {
55
+ for (const n of names) {
56
+ const full = path.join(dir, n);
57
+ if (exists(full)) return full;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** Newest app-bundled CLI under `<localAppData>\github-copilot-sdk\cli\<version>\copilot.exe`, or null. */
64
+ export function findAppBundledCopilot({ localAppData, exists = realDeps.exists, listDir = realDeps.listDir } = {}) {
65
+ if (!localAppData) return null;
66
+ const root = path.join(localAppData, 'github-copilot-sdk', 'cli');
67
+ const versions = listDir(root)
68
+ .filter((v) => exists(path.join(root, v, 'copilot.exe')))
69
+ .sort(compareCopilotVersions);
70
+ const newest = versions.at(-1);
71
+ return newest ? { path: path.join(root, newest, 'copilot.exe'), version: newest } : null;
72
+ }
73
+
74
+ /**
75
+ * Resolve the Copilot CLI. Returns `{ path, source: 'path' | 'app', version? }` or null.
76
+ */
77
+ export function resolveCopilotCli(opts = {}) {
78
+ const env = opts.env ?? process.env;
79
+ const platform = opts.platform ?? process.platform;
80
+ const exists = opts.exists ?? realDeps.exists;
81
+ const listDir = opts.listDir ?? realDeps.listDir;
82
+ const onPath = findCopilotOnPath({ env, platform, exists });
83
+ if (onPath) return { path: onPath, source: 'path' };
84
+ if (platform === 'win32') {
85
+ const app = findAppBundledCopilot({ localAppData: env.LOCALAPPDATA, exists, listDir });
86
+ if (app) return { ...app, source: 'app' };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Read `copilot plugin list` output for the heyspex plugin. A plugin loaded from a local
93
+ * marketplace is only DISABLED by `copilot plugin uninstall` and stays in the list marked
94
+ * `(disabled)`, so "listed" is not "installed": returns `{ listed, enabled }`.
95
+ */
96
+ export function parsePluginList(out) {
97
+ const lines = String(out ?? '').split(/\r?\n/).filter((l) => /heyspex@/i.test(l));
98
+ return { listed: lines.length > 0, enabled: lines.some((l) => !/\(disabled\)|\[disabled\]/i.test(l)) };
99
+ }
100
+
101
+ /**
102
+ * Copilot's settings/state dir: `COPILOT_HOME` when set (Copilot's own override, see
103
+ * `copilot help environment`), else `<home>/.copilot`.
104
+ */
105
+ export function copilotHomeDir({ env = process.env, home = os.homedir() } = {}) {
106
+ const override = typeof env.COPILOT_HOME === 'string' ? env.COPILOT_HOME.trim() : '';
107
+ return override ? path.resolve(override) : path.join(home, '.copilot');
108
+ }