heyspex 0.2.2 → 0.2.4
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/.claude-plugin/marketplace.json +2 -1
- package/README.md +11 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +83 -79
- 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 +200 -177
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +257 -250
- package/plugins/heyspex/.claude-plugin/plugin.json +13 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +16 -15
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -312
- package/plugins/heyspex/assets/heyspex-glyph-128.png +0 -0
- package/plugins/heyspex/assets/heyspex-glyph-256.png +0 -0
- package/plugins/heyspex/assets/heyspex-glyph.svg +12 -0
- 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 +1073 -0
- package/scripts/copilot-statusline.mjs +130 -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 +152 -36
- 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/http-transport.mjs +6 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/server-identity.mjs +13 -0
- 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 +63 -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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// HeySpex status line for GitHub Copilot CLI.
|
|
3
|
+
//
|
|
4
|
+
// Wired by `hs setup copilot --statusline` as `statusLine.command` in
|
|
5
|
+
// <COPILOT_HOME or ~/.copilot>/settings.json. Copilot pipes session status JSON on
|
|
6
|
+
// stdin and shows the first stdout line.
|
|
7
|
+
//
|
|
8
|
+
// This file is only a thin input adapter. All segment logic (model, repo > branch,
|
|
9
|
+
// pinned epic, claimed/relevant spec) is the Claude Code renderer's build() in
|
|
10
|
+
// scripts/claude-statusline/statusline-command.mjs, called with host:'copilot'
|
|
11
|
+
// (no Claude glyph, no Haiku label, no transcript scan, cache-only pin read).
|
|
12
|
+
//
|
|
13
|
+
// Contract: exit 0 always; never print an error or stack trace; on any failure or a
|
|
14
|
+
// hard 1.5 s budget overrun print the minimal "HeySpex" line. Plain ASCII-safe
|
|
15
|
+
// output (marker is "[HS]" unless HEYSPEX_STATUSLINE_GLYPH=1 and the font is installed).
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { build } from './claude-statusline/statusline-command.mjs';
|
|
21
|
+
import { isMainModule } from './lib/shared-path-helpers.mjs';
|
|
22
|
+
|
|
23
|
+
export const FALLBACK_LINE = 'HeySpex';
|
|
24
|
+
const STDIN_TIMEOUT_MS = 500;
|
|
25
|
+
const HARD_BUDGET_MS = 1500;
|
|
26
|
+
|
|
27
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
28
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v : null);
|
|
29
|
+
|
|
30
|
+
/** Copilot statusLine JSON -> the Claude-shaped session object build() reads. Never throws. */
|
|
31
|
+
export function adaptCopilotSession(input) {
|
|
32
|
+
const j = input && typeof input === 'object' ? input : {};
|
|
33
|
+
const cw = j.context_window && typeof j.context_window === 'object' ? j.context_window : {};
|
|
34
|
+
const dir = str(j.workspace?.current_dir) ?? str(j.cwd);
|
|
35
|
+
const used = num(cw.current_context_used_percentage) ?? num(cw.used_percentage);
|
|
36
|
+
const inTok = num(cw.total_input_tokens);
|
|
37
|
+
const outTok = num(cw.total_output_tokens);
|
|
38
|
+
const context_window = {};
|
|
39
|
+
// Zero tokens means "no API call yet" in Copilot; render nothing rather than "0 tok".
|
|
40
|
+
if (inTok !== null && inTok + (outTok ?? 0) > 0) {
|
|
41
|
+
context_window.total_input_tokens = inTok;
|
|
42
|
+
context_window.total_output_tokens = outTok ?? 0;
|
|
43
|
+
}
|
|
44
|
+
if (used !== null) context_window.remaining_percentage = 100 - used;
|
|
45
|
+
return {
|
|
46
|
+
session_id: str(j.session_id),
|
|
47
|
+
cwd: dir,
|
|
48
|
+
workspace: { current_dir: dir },
|
|
49
|
+
model: { display_name: str(j.model?.display_name) ?? str(j.model?.id) },
|
|
50
|
+
context_window,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Nearest dir at or above `start` holding .git or heyspex.yaml; else `start`. */
|
|
55
|
+
export function findRoot(start) {
|
|
56
|
+
let dir = path.resolve(start);
|
|
57
|
+
for (;;) {
|
|
58
|
+
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'heyspex.yaml'))) return dir;
|
|
59
|
+
const up = path.dirname(dir);
|
|
60
|
+
if (up === dir) return path.resolve(start);
|
|
61
|
+
dir = up;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `hs mode off` honoured like the Claude line: env first, then <root>/<dot>heyspex/mode.json. */
|
|
66
|
+
function modeIsOff(root) {
|
|
67
|
+
try {
|
|
68
|
+
const raw = String(process.env.HEYSPEX_MODE ?? '').trim().toLowerCase();
|
|
69
|
+
if (raw) return ['off', '0', 'false', 'disabled', 'disable', 'no'].includes(raw);
|
|
70
|
+
return JSON.parse(fs.readFileSync(path.join(root, '.heyspex', 'mode.json'), 'utf8'))?.mode === 'off';
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Render one line for raw stdin text. Returns '' when HeySpex is switched off. Never throws. */
|
|
77
|
+
export async function renderLine(raw) {
|
|
78
|
+
try {
|
|
79
|
+
let parsed = null;
|
|
80
|
+
try { parsed = raw && raw.trim() ? JSON.parse(raw) : null; } catch { parsed = null; }
|
|
81
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return FALLBACK_LINE;
|
|
82
|
+
const session = adaptCopilotSession(parsed);
|
|
83
|
+
const root = findRoot(session.cwd ?? process.cwd());
|
|
84
|
+
if (modeIsOff(root)) return '';
|
|
85
|
+
const line = await build(root, session, { host: 'copilot' });
|
|
86
|
+
return (line ?? '').split(/\r?\n/)[0].trim() || FALLBACK_LINE;
|
|
87
|
+
} catch {
|
|
88
|
+
return FALLBACK_LINE;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readStdin() {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
if (process.stdin.isTTY) return resolve('');
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let done = false;
|
|
97
|
+
const finish = () => {
|
|
98
|
+
if (done) return;
|
|
99
|
+
done = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
102
|
+
};
|
|
103
|
+
const timer = setTimeout(finish, STDIN_TIMEOUT_MS);
|
|
104
|
+
try {
|
|
105
|
+
process.stdin.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
106
|
+
process.stdin.on('end', finish);
|
|
107
|
+
process.stdin.on('error', finish);
|
|
108
|
+
} catch {
|
|
109
|
+
finish();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function main() {
|
|
115
|
+
const hard = setTimeout(() => {
|
|
116
|
+
try { process.stdout.write(`${FALLBACK_LINE}\n`); } catch { /* ignore */ }
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}, HARD_BUDGET_MS);
|
|
119
|
+
const line = await renderLine(await readStdin());
|
|
120
|
+
clearTimeout(hard);
|
|
121
|
+
if (line) process.stdout.write(`${line}\n`);
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isMainModule(import.meta.url)) {
|
|
126
|
+
main().catch(() => {
|
|
127
|
+
try { process.stdout.write(`${FALLBACK_LINE}\n`); } catch { /* ignore */ }
|
|
128
|
+
process.exit(0);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -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;
|
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
// node scripts/install-copilot-hooks.mjs --print # agent-facing instructions to stdout
|
|
13
13
|
// --target <dir> repo to set up (default: cwd's repo root, walking up to .git / heyspex.yaml)
|
|
14
14
|
// --json (with --check) machine-readable report
|
|
15
|
+
// --statusline install ONLY the HeySpex status line: merges `statusLine` into <COPILOT_HOME or ~/.copilot>/settings.json
|
|
16
|
+
// (backup kept; a foreign statusLine is left alone unless --force). With --uninstall removes only ours.
|
|
17
|
+
// --force (with --statusline) replace a non-HeySpex statusLine
|
|
15
18
|
//
|
|
16
19
|
// Single-source rule: the plugin OR the repo hooks file, never both (every hook would fire twice).
|
|
17
20
|
//
|
|
18
21
|
// Exit codes:
|
|
19
|
-
// 0 success / nothing to do
|
|
20
|
-
// 1 failure: plugin route could not complete, --check found a problem, unexpected error
|
|
22
|
+
// 0 success / nothing to do
|
|
23
|
+
// 1 failure: no Copilot CLI found (nothing installed), plugin route could not complete, --check found a problem, unexpected error
|
|
21
24
|
// 2 refused: --hooks while the heyspex plugin is installed for Copilot, or bad arguments
|
|
22
25
|
//
|
|
23
26
|
// Never prints a stack trace; every path ends with a next step.
|
|
@@ -29,9 +32,11 @@ import { spawnSync } from 'node:child_process';
|
|
|
29
32
|
import { fileURLToPath } from 'node:url';
|
|
30
33
|
|
|
31
34
|
import { detectDoubleFire, invokedDirectly, renderGithubHooksText } from './lib/customer-kit.mjs';
|
|
35
|
+
import { COPILOT_PREREQUISITE, copilotHomeDir, parsePluginList, resolveCopilotCli } from './lib/copilot-cli.mjs';
|
|
32
36
|
|
|
33
37
|
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
38
|
export const HOOKS_REL = ['.github', 'hooks', 'heyspex.json'];
|
|
39
|
+
export const STATUSLINE_REL = ['scripts', 'copilot-statusline.mjs'];
|
|
35
40
|
const FALLBACK = { marketplace: 'heyspex-marketplace', plugin: 'heyspex' };
|
|
36
41
|
|
|
37
42
|
const fsDeps = {
|
|
@@ -72,10 +77,12 @@ export function findRepoRoot(start) {
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
function parseArgs(argv) {
|
|
75
|
-
const a = { hooks: false, check: false, uninstall: false, print: false, json: false, target: null, bad: null };
|
|
80
|
+
const a = { hooks: false, statusline: false, force: false, check: false, uninstall: false, print: false, json: false, target: null, bad: null };
|
|
76
81
|
for (let i = 0; i < argv.length; i++) {
|
|
77
82
|
const x = argv[i];
|
|
78
83
|
if (x === '--hooks') a.hooks = true;
|
|
84
|
+
else if (x === '--statusline') a.statusline = true;
|
|
85
|
+
else if (x === '--force') a.force = true;
|
|
79
86
|
else if (x === '--check') a.check = true;
|
|
80
87
|
else if (x === '--uninstall') a.uninstall = true;
|
|
81
88
|
else if (x === '--print') a.print = true;
|
|
@@ -89,35 +96,46 @@ function parseArgs(argv) {
|
|
|
89
96
|
return a;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Run the resolved Copilot CLI (PATH first, then the Copilot app's bundled copy, see lib/copilot-cli.mjs).
|
|
101
|
+
* A `.cmd`/`.bat` shim on Windows needs a shell; a real `copilot.exe` is spawned directly.
|
|
102
|
+
*/
|
|
103
|
+
function copilotRun(cli, args) {
|
|
104
|
+
if (!cli) return { status: 127, out: '', missing: true };
|
|
93
105
|
try {
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const r = spawnSync('copilot', argv, { encoding: 'utf8', shell: win, timeout: 120000 });
|
|
106
|
+
const shim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(cli.path);
|
|
107
|
+
const quote = (x) => (/[\s&()^]/.test(x) ? `"${x}"` : x);
|
|
108
|
+
const r = spawnSync(shim ? quote(cli.path) : cli.path, shim ? args.map(quote) : args, { encoding: 'utf8', shell: shim, timeout: 120000, env: process.env });
|
|
98
109
|
return { status: r.error ? 127 : (r.status ?? 1), out: `${r.stdout ?? ''}${r.stderr ?? ''}`, missing: !!r.error };
|
|
99
110
|
} catch {
|
|
100
111
|
return { status: 127, out: '', missing: true };
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
/** Resolve the CLI once and confirm it runs; returns { path, source, version? } or null. */
|
|
116
|
+
function locateCopilot() {
|
|
117
|
+
const cli = resolveCopilotCli();
|
|
118
|
+
if (!cli) return null;
|
|
119
|
+
const r = copilotRun(cli, ['--version']);
|
|
120
|
+
return !r.missing && r.status === 0 ? cli : null;
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
const describeCli = (cli) => (cli ? `${cli.path} (${cli.source === 'app' ? 'Copilot app' : 'PATH'})` : 'not found');
|
|
124
|
+
|
|
109
125
|
const q = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
110
126
|
const nextCommand = () => 'npx heyspex setup copilot';
|
|
111
127
|
|
|
112
128
|
/** Is the heyspex plugin installed for Copilot? Combines detectDoubleFire evidence with `copilot plugin list`. */
|
|
113
|
-
function pluginEvidence(targetRoot,
|
|
114
|
-
const
|
|
129
|
+
function pluginEvidence(targetRoot, cli) {
|
|
130
|
+
const copilotHome = copilotHomeDir();
|
|
131
|
+
const df = detectDoubleFire({ targetRoot, home: os.homedir(), copilotHome, deps: fsDeps });
|
|
115
132
|
// 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) =>
|
|
133
|
+
const evidence = df.plugin === true ? df.evidence.filter((e) => e.includes(copilotHome)) : [];
|
|
117
134
|
let plugin = evidence.length > 0;
|
|
118
|
-
if (
|
|
119
|
-
const r = copilotRun(['plugin', 'list']);
|
|
120
|
-
|
|
135
|
+
if (cli) {
|
|
136
|
+
const r = copilotRun(cli, ['plugin', 'list']);
|
|
137
|
+
const listed = parsePluginList(r.out);
|
|
138
|
+
if (r.status === 0 && listed.enabled) {
|
|
121
139
|
plugin = true;
|
|
122
140
|
evidence.push('`copilot plugin list` names heyspex');
|
|
123
141
|
}
|
|
@@ -139,6 +157,80 @@ function hooksFileState(target) {
|
|
|
139
157
|
return { file, exists: true, ours, current: raw.replace(/\r\n/g, '\n') === renderGithubHooksText() };
|
|
140
158
|
}
|
|
141
159
|
|
|
160
|
+
// ---- status line (opt-in) ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export const STATUSLINE_REFRESH_SECS = 30;
|
|
163
|
+
const settingsPath = () => path.join(copilotHomeDir(), 'settings.json');
|
|
164
|
+
|
|
165
|
+
/** The statusLine command: node + the INSTALLED script, quoted so paths with spaces survive cmd.exe and sh. */
|
|
166
|
+
export function statuslineCommand(pkgRoot = PACKAGE_ROOT, platform = process.platform) {
|
|
167
|
+
const script = path.join(pkgRoot, ...STATUSLINE_REL);
|
|
168
|
+
return platform === 'win32' ? `node "${script}"` : `node '${script.replace(/'/g, "'\\''")}'`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const isOurStatusLine = (sl) => !!sl && typeof sl === 'object' && typeof sl.command === 'string' && /copilot-statusline\.mjs/.test(sl.command);
|
|
172
|
+
|
|
173
|
+
/** { file, exists, json, statusLine, error } for <COPILOT_HOME>/settings.json. */
|
|
174
|
+
export function readCopilotSettings(file = settingsPath()) {
|
|
175
|
+
const raw = fsDeps.readFile(file);
|
|
176
|
+
if (raw === null) return { file, exists: false, json: {}, statusLine: null, error: null };
|
|
177
|
+
try {
|
|
178
|
+
const json = raw.trim() ? JSON.parse(raw) : {};
|
|
179
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error('not a JSON object');
|
|
180
|
+
return { file, exists: true, json, statusLine: json.statusLine ?? null, error: null };
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return { file, exists: true, json: null, statusLine: null, error: e?.message ?? String(e) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function backupSettings(file) {
|
|
187
|
+
const bak = `${file}.heyspex-bak`;
|
|
188
|
+
if (!fs.existsSync(bak)) fs.copyFileSync(file, bak);
|
|
189
|
+
return bak;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function writeSettings(file, json) {
|
|
193
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
194
|
+
fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}
|
|
195
|
+
`, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function doStatusline(a, out, err) {
|
|
199
|
+
const script = path.join(PACKAGE_ROOT, ...STATUSLINE_REL);
|
|
200
|
+
const st = readCopilotSettings();
|
|
201
|
+
if (a.uninstall) {
|
|
202
|
+
if (st.error) { err(`hs setup copilot --statusline --uninstall: ${st.file} is not valid JSON (${st.error}); not touching it.`); return 1; }
|
|
203
|
+
if (!st.statusLine) { out(`no statusLine in ${st.file}; nothing to remove`); return 0; }
|
|
204
|
+
if (!isOurStatusLine(st.statusLine)) { out(`left the statusLine in ${st.file} alone: it is not the HeySpex one`); return 0; }
|
|
205
|
+
backupSettings(st.file);
|
|
206
|
+
const { statusLine: _drop, ...rest } = st.json;
|
|
207
|
+
writeSettings(st.file, rest);
|
|
208
|
+
out(`removed the HeySpex statusLine from ${st.file} (other keys kept; backup ${st.file}.heyspex-bak)`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
if (!fs.existsSync(script)) {
|
|
212
|
+
err(`hs setup copilot --statusline: ${script} not found. The heyspex package looks incomplete; run \`npm install heyspex@latest\` and retry.`);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
if (st.error) { err(`hs setup copilot --statusline: ${st.file} is not valid JSON (${st.error}); fix or move it, then re-run. Nothing was changed.`); return 1; }
|
|
216
|
+
if (st.statusLine && !isOurStatusLine(st.statusLine) && !a.force) {
|
|
217
|
+
err(`hs setup copilot --statusline: ${st.file} already has a non-HeySpex statusLine (${JSON.stringify(st.statusLine.command ?? st.statusLine)}); not overwriting it.`);
|
|
218
|
+
err('Re-run with --force to replace it (a backup is kept).');
|
|
219
|
+
return 2;
|
|
220
|
+
}
|
|
221
|
+
const next = { type: 'command', command: statuslineCommand(), refreshInterval: STATUSLINE_REFRESH_SECS };
|
|
222
|
+
if (JSON.stringify(st.statusLine) === JSON.stringify(next)) {
|
|
223
|
+
out(`already current: statusLine in ${st.file}`);
|
|
224
|
+
} else {
|
|
225
|
+
if (st.exists) out(`backup: ${backupSettings(st.file)}`);
|
|
226
|
+
writeSettings(st.file, { ...st.json, statusLine: next });
|
|
227
|
+
out(`${st.statusLine ? 'updated' : 'wrote'} statusLine in ${st.file}`);
|
|
228
|
+
}
|
|
229
|
+
if (/[\\/]_npx[\\/]/.test(PACKAGE_ROOT)) out('WARNING: this ran from the npx cache, which can be pruned. Run it from a project that has heyspex installed (npm install heyspex) so the path stays valid.');
|
|
230
|
+
out('Next: restart Copilot (or /restart); the status line renders in interactive sessions only.');
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
142
234
|
function manualCommands(spec) {
|
|
143
235
|
return [
|
|
144
236
|
`copilot plugin marketplace add ${q(PACKAGE_ROOT)}`,
|
|
@@ -161,8 +253,8 @@ function doPrint(out) {
|
|
|
161
253
|
|
|
162
254
|
function doCheck(a, target, out) {
|
|
163
255
|
const spec = resolvePluginSpec(PACKAGE_ROOT);
|
|
164
|
-
const
|
|
165
|
-
const ev = pluginEvidence(target,
|
|
256
|
+
const cli = locateCopilot();
|
|
257
|
+
const ev = pluginEvidence(target, cli);
|
|
166
258
|
const hooks = hooksFileState(target);
|
|
167
259
|
const route = ev.plugin && hooks.exists ? 'both' : ev.plugin ? 'plugin' : hooks.exists ? 'hooks' : 'none';
|
|
168
260
|
const problems = [];
|
|
@@ -171,7 +263,12 @@ function doCheck(a, target, out) {
|
|
|
171
263
|
if (route === 'hooks' && !hooks.ours) problems.push('.github/hooks/heyspex.json is not a HeySpex file');
|
|
172
264
|
if (route === 'hooks' && hooks.ours && !hooks.current) problems.push('.github/hooks/heyspex.json is stale; re-run `npx heyspex setup copilot --hooks`');
|
|
173
265
|
if (route === 'none') problems.push(`no route active; run \`${nextCommand()}\` (plugin) or \`${nextCommand()} --hooks\``);
|
|
174
|
-
|
|
266
|
+
if (!cli && route === 'none') problems.push(`Copilot CLI not found (not on PATH, no Copilot app copy). ${COPILOT_PREREQUISITE}`);
|
|
267
|
+
const sl = readCopilotSettings();
|
|
268
|
+
const slState = sl.error ? 'unreadable' : !sl.statusLine ? 'absent' : isOurStatusLine(sl.statusLine) ? 'heyspex' : 'foreign';
|
|
269
|
+
const slScriptOk = fs.existsSync(path.join(PACKAGE_ROOT, ...STATUSLINE_REL));
|
|
270
|
+
if (slState === 'heyspex' && !slScriptOk) problems.push('statusLine points at a HeySpex script that is missing from this package; re-run `npx heyspex setup copilot --statusline`');
|
|
271
|
+
const report = { statusLine: slState, settingsPath: sl.file, 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
272
|
if (a.json) out(JSON.stringify(report, null, 2));
|
|
176
273
|
else {
|
|
177
274
|
out(`hs setup copilot --check (target ${target})`);
|
|
@@ -179,7 +276,9 @@ function doCheck(a, target, out) {
|
|
|
179
276
|
out(` plugin installed: ${ev.plugin ? 'yes' : 'no'}${ev.evidence.length ? ` (${ev.evidence.join('; ')})` : ''}`);
|
|
180
277
|
out(` hooks file: ${hooks.exists ? `present${hooks.ours ? '' : ' (not HeySpex)'}${hooks.current ? ', current' : ', stale'}` : 'absent'}`);
|
|
181
278
|
out(` marketplace manifest in package: ${spec.present ? 'present' : 'MISSING'}`);
|
|
182
|
-
out(` copilot CLI
|
|
279
|
+
out(` copilot CLI: ${describeCli(cli)}`);
|
|
280
|
+
out(` statusLine: ${slState}${slState === 'heyspex' ? ` (${sl.statusLine.command})` : slState === 'foreign' ? ` (${JSON.stringify(sl.statusLine.command ?? sl.statusLine)}; not ours)` : slState === 'absent' ? ' (opt in: `npx heyspex setup copilot --statusline`)' : ` (${sl.error})`} in ${sl.file}`);
|
|
281
|
+
if (process.env.COPILOT_HOME) out(` COPILOT_HOME: ${copilotHomeDir()}`);
|
|
183
282
|
for (const p of problems) out(` PROBLEM: ${p}`);
|
|
184
283
|
if (!problems.length) out(' ok');
|
|
185
284
|
}
|
|
@@ -187,7 +286,7 @@ function doCheck(a, target, out) {
|
|
|
187
286
|
}
|
|
188
287
|
|
|
189
288
|
function doHooks(target, out, err) {
|
|
190
|
-
const ev = pluginEvidence(target,
|
|
289
|
+
const ev = pluginEvidence(target, locateCopilot());
|
|
191
290
|
if (ev.plugin) {
|
|
192
291
|
err(`hs setup copilot --hooks refused: the heyspex plugin is already installed for Copilot (${ev.evidence.join('; ')}).`);
|
|
193
292
|
err('Plugin OR hooks, never both (every hook would fire twice). Remove the plugin first: `copilot plugin uninstall heyspex`.');
|
|
@@ -222,20 +321,23 @@ function doPlugin(target, out, err) {
|
|
|
222
321
|
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
322
|
}
|
|
224
323
|
const cmds = manualCommands(spec.spec);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
324
|
+
const cli = locateCopilot();
|
|
325
|
+
if (!cli) {
|
|
326
|
+
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.');
|
|
327
|
+
err(COPILOT_PREREQUISITE);
|
|
328
|
+
err('Then re-run `npx heyspex setup copilot`, or run these two commands by hand:');
|
|
329
|
+
for (const c of cmds) err(` ${c}`);
|
|
330
|
+
return 1;
|
|
230
331
|
}
|
|
231
|
-
|
|
332
|
+
out(`using Copilot CLI: ${describeCli(cli)}`);
|
|
333
|
+
const add = copilotRun(cli, ['plugin', 'marketplace', 'add', PACKAGE_ROOT]);
|
|
232
334
|
if (add.status !== 0 && !/already/i.test(add.out)) {
|
|
233
335
|
err(`copilot plugin marketplace add failed:\n${add.out.trim().slice(-600)}`);
|
|
234
336
|
err('Run these by hand and report the output:');
|
|
235
337
|
for (const c of cmds) err(` ${c}`);
|
|
236
338
|
return 1;
|
|
237
339
|
}
|
|
238
|
-
const inst = copilotRun(['plugin', 'install', spec.spec]);
|
|
340
|
+
const inst = copilotRun(cli, ['plugin', 'install', spec.spec]);
|
|
239
341
|
if (inst.status !== 0 && !/already/i.test(inst.out)) {
|
|
240
342
|
err(`copilot plugin install failed:\n${inst.out.trim().slice(-600)}`);
|
|
241
343
|
err('Run this by hand and report the output:');
|
|
@@ -247,16 +349,29 @@ function doPlugin(target, out, err) {
|
|
|
247
349
|
return 0;
|
|
248
350
|
}
|
|
249
351
|
|
|
250
|
-
function doUninstall(target, out) {
|
|
352
|
+
function doUninstall(target, out, err) {
|
|
251
353
|
const hooks = hooksFileState(target);
|
|
252
354
|
if (!hooks.exists) out(`no hooks file at ${hooks.file}`);
|
|
253
355
|
else if (!hooks.ours) out(`left ${hooks.file} alone: it is not a HeySpex-only file`);
|
|
254
356
|
else { fs.rmSync(hooks.file, { force: true }); out(`removed ${hooks.file}`); }
|
|
255
357
|
const cmd = 'copilot plugin uninstall heyspex';
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
358
|
+
const cli = locateCopilot();
|
|
359
|
+
if (cli) {
|
|
360
|
+
const r = copilotRun(cli, ['plugin', 'uninstall', 'heyspex']);
|
|
361
|
+
out(r.status === 0 ? `ran \`${cmd}\` via ${describeCli(cli)}` : `\`${cmd}\` did not complete (it may not be installed): ${r.out.trim().slice(-200)}`);
|
|
362
|
+
// A plugin loaded from a local marketplace is only DISABLED by `plugin uninstall` (it stays
|
|
363
|
+
// in `plugin list`), so drop the marketplace we registered too.
|
|
364
|
+
const mk = copilotRun(cli, ['plugin', 'marketplace', 'remove', resolvePluginSpec(PACKAGE_ROOT).marketplace]);
|
|
365
|
+
if (mk.status === 0) out(`removed marketplace ${resolvePluginSpec(PACKAGE_ROOT).marketplace}`);
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
// No CLI: only an error when a plugin install is actually on record and could not be removed.
|
|
369
|
+
if (pluginEvidence(target, null).plugin) {
|
|
370
|
+
err(`hs setup copilot --uninstall: the heyspex plugin is installed for Copilot but no Copilot CLI was found to remove it. ${COPILOT_PREREQUISITE}`);
|
|
371
|
+
err(`Then run: ${cmd}`);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
out(`no heyspex plugin found for Copilot; nothing more to remove (to be sure, run: ${cmd})`);
|
|
260
375
|
return 0;
|
|
261
376
|
}
|
|
262
377
|
|
|
@@ -267,13 +382,14 @@ export function main(argv = process.argv.slice(2), io = {}) {
|
|
|
267
382
|
const a = parseArgs(argv);
|
|
268
383
|
if (a.bad) {
|
|
269
384
|
err(`hs setup copilot: ${a.bad}`);
|
|
270
|
-
err('usage: hs setup copilot [--hooks] [--check [--json]] [--uninstall] [--print] [--target <dir>]');
|
|
385
|
+
err('usage: hs setup copilot [--hooks] [--statusline [--force]] [--check [--json]] [--uninstall] [--print] [--target <dir>]');
|
|
271
386
|
return 2;
|
|
272
387
|
}
|
|
273
388
|
if (a.print) return doPrint(out);
|
|
274
389
|
const target = a.target ? path.resolve(a.target) : findRepoRoot(process.env.HS_SETUP_TARGET?.trim() || process.cwd());
|
|
275
390
|
if (a.check) return doCheck(a, target, out);
|
|
276
|
-
if (a.
|
|
391
|
+
if (a.statusline) return doStatusline(a, out, err);
|
|
392
|
+
if (a.uninstall) return doUninstall(target, out, err);
|
|
277
393
|
if (a.hooks) return doHooks(target, out, err);
|
|
278
394
|
return doPlugin(target, out, err);
|
|
279
395
|
} 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
|
+
}
|