heyspex 0.2.1 → 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.
- package/README.md +10 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +99 -81
- package/bin/spexcode.mjs +91 -0
- package/dev-factory/engine/worktree.mjs +36 -7
- package/dev-factory/lib/epic-detect.mjs +2 -2
- package/dev-factory/lib/fleet-readiness.mjs +3 -5
- package/dev-factory/lib/fleet-state.mjs +2 -2
- package/dev-factory/lib/sufficiency-score.mjs +3 -5
- package/dist/cli/hs-check.mjs +76 -46
- package/dist/cli/hs-claim.mjs +63 -33
- package/dist/cli/hs-connect.mjs +520 -214
- package/dist/cli/hs-defer.mjs +161 -19
- package/dist/cli/hs-dev.mjs +11 -5
- package/dist/cli/hs-env.mjs +187 -161
- package/dist/cli/hs-index-rebuild.mjs +123 -78
- package/dist/cli/hs-index-reconcile.mjs +203 -107
- package/dist/cli/hs-init.mjs +426 -179
- package/dist/cli/hs-install.mjs +759 -437
- package/dist/cli/hs-join.mjs +590 -383
- package/dist/cli/hs-vault-pull.mjs +359 -254
- package/dist/cli/hs-vault-push.mjs +151 -113
- package/dist/cli/hs-vault.mjs +271 -104
- package/dist/cli/hs-workspace.mjs +90 -57
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
- package/docs/copilot-plugin-install.md +189 -162
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +255 -249
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -309
- package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
- package/plugins/heyspex/mcp.json +12 -13
- package/scripts/check-mcp-freshness.mjs +6 -4
- package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
- package/scripts/claude-hooks/research-method-guard.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
- package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
- package/scripts/claude-statusline/statusline-command.mjs +1058 -0
- package/scripts/dev-server-status.mjs +3 -2
- package/scripts/dev-server-stop.mjs +3 -2
- package/scripts/hs-capture-cli.mjs +2 -2
- package/scripts/hs-settings.mjs +2 -2
- package/scripts/hs-view.mjs +3 -2
- package/scripts/install-copilot-hooks.mjs +318 -0
- package/scripts/lib/copilot-cli.mjs +108 -0
- package/scripts/lib/customer-kit.mjs +223 -30
- package/scripts/lib/dev-mode.mjs +28 -2
- package/scripts/lib/shared-path-helpers.mjs +32 -0
- package/scripts/lib/standalone-doctor.mjs +131 -32
- package/scripts/wip-ledger-health.mjs +3 -2
- package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
- package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
- package/spexharness/src/intent-directive.mjs +839 -626
- package/spexharness/src/rule-table.mjs +1 -1
- package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
- package/vendor/heyspex-mcp/cli.mjs +20 -4
- package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
- package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
- package/vendor/heyspex-mcp/events-tools.mjs +1 -1
- package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
- package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
- package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
- package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
- 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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
|
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);
|
package/scripts/hs-settings.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
331
|
+
return isMainModule(import.meta.url);
|
|
332
332
|
} catch {
|
|
333
333
|
return false;
|
|
334
334
|
}
|
package/scripts/hs-view.mjs
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { spawn } from 'node:child_process';
|
|
25
25
|
import path from 'node:path';
|
|
26
|
-
import { fileURLToPath
|
|
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
|
|
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;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// `hs setup copilot` -- install HeySpex into GitHub Copilot CLI / cloud agent.
|
|
2
|
+
//
|
|
3
|
+
// Works from the published npm package only (node_modules/heyspex, or
|
|
4
|
+
// `npx heyspex setup copilot`) and from a platform checkout. The package root
|
|
5
|
+
// is resolved from THIS file's location, never from the cwd.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// node scripts/install-copilot-hooks.mjs # PLUGIN route (default)
|
|
9
|
+
// node scripts/install-copilot-hooks.mjs --hooks # HOOKS route: .github/hooks/heyspex.json
|
|
10
|
+
// node scripts/install-copilot-hooks.mjs --check # report active route; exit 1 if misconfigured
|
|
11
|
+
// node scripts/install-copilot-hooks.mjs --uninstall
|
|
12
|
+
// node scripts/install-copilot-hooks.mjs --print # agent-facing instructions to stdout
|
|
13
|
+
// --target <dir> repo to set up (default: cwd's repo root, walking up to .git / heyspex.yaml)
|
|
14
|
+
// --json (with --check) machine-readable report
|
|
15
|
+
//
|
|
16
|
+
// Single-source rule: the plugin OR the repo hooks file, never both (every hook would fire twice).
|
|
17
|
+
//
|
|
18
|
+
// Exit codes:
|
|
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
|
+
// 2 refused: --hooks while the heyspex plugin is installed for Copilot, or bad arguments
|
|
22
|
+
//
|
|
23
|
+
// Never prints a stack trace; every path ends with a next step.
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
import { detectDoubleFire, invokedDirectly, renderGithubHooksText } from './lib/customer-kit.mjs';
|
|
32
|
+
import { COPILOT_PREREQUISITE, copilotHomeDir, parsePluginList, resolveCopilotCli } from './lib/copilot-cli.mjs';
|
|
33
|
+
|
|
34
|
+
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
export const HOOKS_REL = ['.github', 'hooks', 'heyspex.json'];
|
|
36
|
+
const FALLBACK = { marketplace: 'heyspex-marketplace', plugin: 'heyspex' };
|
|
37
|
+
|
|
38
|
+
const fsDeps = {
|
|
39
|
+
readFile: (p) => {
|
|
40
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
41
|
+
},
|
|
42
|
+
listDir: (p) => {
|
|
43
|
+
try { return fs.readdirSync(p); } catch { return []; }
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Tiny .mjs twin of install.ts resolvePluginSpec: names come from the package's marketplace.json. */
|
|
48
|
+
export function resolvePluginSpec(platformRoot) {
|
|
49
|
+
const manifest = path.join(platformRoot, '.claude-plugin', 'marketplace.json');
|
|
50
|
+
const raw = fsDeps.readFile(manifest);
|
|
51
|
+
const out = { manifest, present: raw !== null, marketplace: FALLBACK.marketplace, plugin: FALLBACK.plugin };
|
|
52
|
+
if (raw !== null) {
|
|
53
|
+
try {
|
|
54
|
+
const j = JSON.parse(raw);
|
|
55
|
+
if (typeof j.name === 'string' && j.name.trim()) out.marketplace = j.name.trim();
|
|
56
|
+
const p = Array.isArray(j.plugins) ? j.plugins.find((x) => typeof x?.name === 'string' && x.name.trim()) : null;
|
|
57
|
+
if (p) out.plugin = p.name.trim();
|
|
58
|
+
} catch { /* keep fallback names */ }
|
|
59
|
+
}
|
|
60
|
+
out.spec = `${out.plugin}@${out.marketplace}`;
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Walk up from `start` to the nearest dir holding .git or heyspex.yaml; else `start`. */
|
|
65
|
+
export function findRepoRoot(start) {
|
|
66
|
+
let dir = path.resolve(start);
|
|
67
|
+
for (;;) {
|
|
68
|
+
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'heyspex.yaml'))) return dir;
|
|
69
|
+
const up = path.dirname(dir);
|
|
70
|
+
if (up === dir) return path.resolve(start);
|
|
71
|
+
dir = up;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const a = { hooks: false, check: false, uninstall: false, print: false, json: false, target: null, bad: null };
|
|
77
|
+
for (let i = 0; i < argv.length; i++) {
|
|
78
|
+
const x = argv[i];
|
|
79
|
+
if (x === '--hooks') a.hooks = true;
|
|
80
|
+
else if (x === '--check') a.check = true;
|
|
81
|
+
else if (x === '--uninstall') a.uninstall = true;
|
|
82
|
+
else if (x === '--print') a.print = true;
|
|
83
|
+
else if (x === '--json') a.json = true;
|
|
84
|
+
else if (x === '--target') {
|
|
85
|
+
a.target = argv[++i] ?? null;
|
|
86
|
+
if (!a.target) a.bad = '--target needs a directory';
|
|
87
|
+
} else if (x.startsWith('--target=')) a.target = x.slice(9);
|
|
88
|
+
else a.bad = `unknown argument ${x}`;
|
|
89
|
+
}
|
|
90
|
+
return a;
|
|
91
|
+
}
|
|
92
|
+
|
|
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 };
|
|
99
|
+
try {
|
|
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 });
|
|
103
|
+
return { status: r.error ? 127 : (r.status ?? 1), out: `${r.stdout ?? ''}${r.stderr ?? ''}`, missing: !!r.error };
|
|
104
|
+
} catch {
|
|
105
|
+
return { status: 127, out: '', missing: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
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;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const describeCli = (cli) => (cli ? `${cli.path} (${cli.source === 'app' ? 'Copilot app' : 'PATH'})` : 'not found');
|
|
118
|
+
|
|
119
|
+
const q = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
120
|
+
const nextCommand = () => 'npx heyspex setup copilot';
|
|
121
|
+
|
|
122
|
+
/** Is the heyspex plugin installed for Copilot? Combines detectDoubleFire evidence with `copilot plugin list`. */
|
|
123
|
+
function pluginEvidence(targetRoot, cli) {
|
|
124
|
+
const copilotHome = copilotHomeDir();
|
|
125
|
+
const df = detectDoubleFire({ targetRoot, home: os.homedir(), copilotHome, deps: fsDeps });
|
|
126
|
+
// Only Copilot-side evidence counts: a Claude Code plugin install (~/.claude) is not a Copilot install.
|
|
127
|
+
const evidence = df.plugin === true ? df.evidence.filter((e) => e.includes(copilotHome)) : [];
|
|
128
|
+
let plugin = evidence.length > 0;
|
|
129
|
+
if (cli) {
|
|
130
|
+
const r = copilotRun(cli, ['plugin', 'list']);
|
|
131
|
+
const listed = parsePluginList(r.out);
|
|
132
|
+
if (r.status === 0 && listed.enabled) {
|
|
133
|
+
plugin = true;
|
|
134
|
+
evidence.push('`copilot plugin list` names heyspex');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { plugin, evidence };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Parse the hooks file; ours = every command row is a HeySpex row. */
|
|
141
|
+
function hooksFileState(target) {
|
|
142
|
+
const file = path.join(target, ...HOOKS_REL);
|
|
143
|
+
const raw = fsDeps.readFile(file);
|
|
144
|
+
if (raw === null) return { file, exists: false, ours: false, current: false };
|
|
145
|
+
let ours = false;
|
|
146
|
+
try {
|
|
147
|
+
const j = JSON.parse(raw);
|
|
148
|
+
const cmds = Object.values(j?.hooks ?? {}).flat().map((r) => r?.bash ?? r?.command).filter((c) => typeof c === 'string');
|
|
149
|
+
ours = cmds.length > 0 && cmds.every((c) => /claude-hooks\/_run\.mjs/.test(c));
|
|
150
|
+
} catch { /* foreign / unparseable */ }
|
|
151
|
+
return { file, exists: true, ours, current: raw.replace(/\r\n/g, '\n') === renderGithubHooksText() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function manualCommands(spec) {
|
|
155
|
+
return [
|
|
156
|
+
`copilot plugin marketplace add ${q(PACKAGE_ROOT)}`,
|
|
157
|
+
`copilot plugin install ${spec}`,
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function doPrint(out) {
|
|
162
|
+
out(`Next: run \`${nextCommand()}\` (plugin) or \`${nextCommand()} --hooks\` (cloud agent) in this repo, then /restart.`);
|
|
163
|
+
const doc = fsDeps.readFile(path.join(PACKAGE_ROOT, 'docs', 'copilot-plugin-install.md'));
|
|
164
|
+
if (doc !== null) { out(''); out(doc.replace(/\s+$/, '')); return 0; }
|
|
165
|
+
const spec = resolvePluginSpec(PACKAGE_ROOT);
|
|
166
|
+
out('');
|
|
167
|
+
out('# Installing HeySpex in GitHub Copilot CLI (built-in fallback; docs/copilot-plugin-install.md not found)');
|
|
168
|
+
out('Run these, in order, and stop at the first failure:');
|
|
169
|
+
for (const c of manualCommands(spec.spec)) out(` ${c}`);
|
|
170
|
+
out(' npx hs doctor');
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function doCheck(a, target, out) {
|
|
175
|
+
const spec = resolvePluginSpec(PACKAGE_ROOT);
|
|
176
|
+
const cli = locateCopilot();
|
|
177
|
+
const ev = pluginEvidence(target, cli);
|
|
178
|
+
const hooks = hooksFileState(target);
|
|
179
|
+
const route = ev.plugin && hooks.exists ? 'both' : ev.plugin ? 'plugin' : hooks.exists ? 'hooks' : 'none';
|
|
180
|
+
const problems = [];
|
|
181
|
+
if (route === 'both') problems.push('double-fire: the plugin AND .github/hooks/heyspex.json are active, so every hook runs twice. Run `npx heyspex setup copilot --uninstall` (hooks file) or `copilot plugin uninstall heyspex`.');
|
|
182
|
+
if (!spec.present) problems.push(`marketplace manifest missing: ${spec.manifest}`);
|
|
183
|
+
if (route === 'hooks' && !hooks.ours) problems.push('.github/hooks/heyspex.json is not a HeySpex file');
|
|
184
|
+
if (route === 'hooks' && hooks.ours && !hooks.current) problems.push('.github/hooks/heyspex.json is stale; re-run `npx heyspex setup copilot --hooks`');
|
|
185
|
+
if (route === 'none') problems.push(`no route active; run \`${nextCommand()}\` (plugin) or \`${nextCommand()} --hooks\``);
|
|
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 };
|
|
188
|
+
if (a.json) out(JSON.stringify(report, null, 2));
|
|
189
|
+
else {
|
|
190
|
+
out(`hs setup copilot --check (target ${target})`);
|
|
191
|
+
out(` route: ${route}${route === 'hooks' ? ' (hooks route: .github/hooks/heyspex.json)' : route === 'plugin' ? ' (plugin route)' : ''}`);
|
|
192
|
+
out(` plugin installed: ${ev.plugin ? 'yes' : 'no'}${ev.evidence.length ? ` (${ev.evidence.join('; ')})` : ''}`);
|
|
193
|
+
out(` hooks file: ${hooks.exists ? `present${hooks.ours ? '' : ' (not HeySpex)'}${hooks.current ? ', current' : ', stale'}` : 'absent'}`);
|
|
194
|
+
out(` marketplace manifest in package: ${spec.present ? 'present' : 'MISSING'}`);
|
|
195
|
+
out(` copilot CLI: ${describeCli(cli)}`);
|
|
196
|
+
if (process.env.COPILOT_HOME) out(` COPILOT_HOME: ${copilotHomeDir()}`);
|
|
197
|
+
for (const p of problems) out(` PROBLEM: ${p}`);
|
|
198
|
+
if (!problems.length) out(' ok');
|
|
199
|
+
}
|
|
200
|
+
return problems.length ? 1 : 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function doHooks(target, out, err) {
|
|
204
|
+
const ev = pluginEvidence(target, locateCopilot());
|
|
205
|
+
if (ev.plugin) {
|
|
206
|
+
err(`hs setup copilot --hooks refused: the heyspex plugin is already installed for Copilot (${ev.evidence.join('; ')}).`);
|
|
207
|
+
err('Plugin OR hooks, never both (every hook would fire twice). Remove the plugin first: `copilot plugin uninstall heyspex`.');
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
const hooks = hooksFileState(target);
|
|
211
|
+
if (hooks.exists && !hooks.ours) {
|
|
212
|
+
err(`hs setup copilot --hooks refused: ${hooks.file} exists and is not a HeySpex file; not overwriting it.`);
|
|
213
|
+
return 2;
|
|
214
|
+
}
|
|
215
|
+
if (hooks.exists && hooks.current) {
|
|
216
|
+
out(`already current: ${hooks.file}`);
|
|
217
|
+
} else {
|
|
218
|
+
fs.mkdirSync(path.dirname(hooks.file), { recursive: true });
|
|
219
|
+
fs.writeFileSync(hooks.file, renderGithubHooksText(), 'utf8');
|
|
220
|
+
out(`${hooks.exists ? 'updated' : 'wrote'} ${hooks.file}`);
|
|
221
|
+
}
|
|
222
|
+
out('Commit .github/hooks/heyspex.json so the Copilot cloud agent and teammates get it. Commands resolve node_modules/heyspex first and exit 0 when it is absent.');
|
|
223
|
+
out('Next: `npx heyspex doctor`, then /restart in your Copilot session.');
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function doPlugin(target, out, err) {
|
|
228
|
+
const spec = resolvePluginSpec(PACKAGE_ROOT);
|
|
229
|
+
if (!spec.present) {
|
|
230
|
+
err(`hs setup copilot: marketplace manifest not found at ${spec.manifest}`);
|
|
231
|
+
err('The heyspex package looks incomplete. Reinstall it: `npm install heyspex@latest`, then re-run.');
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
const hooks = hooksFileState(target);
|
|
235
|
+
if (hooks.exists) {
|
|
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.`);
|
|
237
|
+
}
|
|
238
|
+
const cmds = manualCommands(spec.spec);
|
|
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;
|
|
246
|
+
}
|
|
247
|
+
out(`using Copilot CLI: ${describeCli(cli)}`);
|
|
248
|
+
const add = copilotRun(cli, ['plugin', 'marketplace', 'add', PACKAGE_ROOT]);
|
|
249
|
+
if (add.status !== 0 && !/already/i.test(add.out)) {
|
|
250
|
+
err(`copilot plugin marketplace add failed:\n${add.out.trim().slice(-600)}`);
|
|
251
|
+
err('Run these by hand and report the output:');
|
|
252
|
+
for (const c of cmds) err(` ${c}`);
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
const inst = copilotRun(cli, ['plugin', 'install', spec.spec]);
|
|
256
|
+
if (inst.status !== 0 && !/already/i.test(inst.out)) {
|
|
257
|
+
err(`copilot plugin install failed:\n${inst.out.trim().slice(-600)}`);
|
|
258
|
+
err('Run this by hand and report the output:');
|
|
259
|
+
err(` ${cmds[1]}`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
out(`installed ${spec.spec} (marketplace from ${PACKAGE_ROOT})`);
|
|
263
|
+
out('Next: /restart in Copilot, then `copilot plugin list` and `npx heyspex doctor`.');
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function doUninstall(target, out, err) {
|
|
268
|
+
const hooks = hooksFileState(target);
|
|
269
|
+
if (!hooks.exists) out(`no hooks file at ${hooks.file}`);
|
|
270
|
+
else if (!hooks.ours) out(`left ${hooks.file} alone: it is not a HeySpex-only file`);
|
|
271
|
+
else { fs.rmSync(hooks.file, { force: true }); out(`removed ${hooks.file}`); }
|
|
272
|
+
const cmd = 'copilot plugin uninstall heyspex';
|
|
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})`);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function main(argv = process.argv.slice(2), io = {}) {
|
|
294
|
+
const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
|
|
295
|
+
const err = io.err ?? ((s) => process.stderr.write(`${s}\n`));
|
|
296
|
+
try {
|
|
297
|
+
const a = parseArgs(argv);
|
|
298
|
+
if (a.bad) {
|
|
299
|
+
err(`hs setup copilot: ${a.bad}`);
|
|
300
|
+
err('usage: hs setup copilot [--hooks] [--check [--json]] [--uninstall] [--print] [--target <dir>]');
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
if (a.print) return doPrint(out);
|
|
304
|
+
const target = a.target ? path.resolve(a.target) : findRepoRoot(process.env.HS_SETUP_TARGET?.trim() || process.cwd());
|
|
305
|
+
if (a.check) return doCheck(a, target, out);
|
|
306
|
+
if (a.uninstall) return doUninstall(target, out, err);
|
|
307
|
+
if (a.hooks) return doHooks(target, out, err);
|
|
308
|
+
return doPlugin(target, out, err);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
err(`hs setup copilot: unexpected failure: ${e?.message ?? e}`);
|
|
311
|
+
err('Manual fallback: copilot plugin marketplace add <node_modules/heyspex>; copilot plugin install heyspex@heyspex-marketplace');
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (invokedDirectly(import.meta.url, 'install-copilot-hooks.mjs')) {
|
|
317
|
+
process.exit(main());
|
|
318
|
+
}
|
|
@@ -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
|
+
}
|