@hegel-dev/companion 1.0.7 → 1.0.9
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 +14 -2
- package/dist/commands/cleanup.d.ts +3 -0
- package/dist/commands/cleanup.d.ts.map +1 -0
- package/dist/commands/cleanup.js +51 -0
- package/dist/commands/cleanup.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +142 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/types.d.ts +22 -0
- package/dist/commands/types.d.ts.map +1 -0
- package/dist/commands/types.js +23 -0
- package/dist/commands/types.js.map +1 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +90 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/config.d.ts +0 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -1
- package/dist/config.js.map +1 -1
- package/dist/hook.d.ts +0 -17
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +3 -26
- package/dist/hook.js.map +1 -1
- package/dist/hooks-generator.d.ts +0 -4
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +2 -7
- package/dist/hooks-generator.js.map +1 -1
- package/dist/prompts.d.ts +2 -2
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +4 -24
- package/dist/prompts.js.map +1 -1
- package/dist/setup.d.ts +9 -90
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +24 -548
- package/dist/setup.js.map +1 -1
- package/dist/utils/install.d.ts +13 -0
- package/dist/utils/install.d.ts.map +1 -0
- package/dist/utils/install.js +112 -0
- package/dist/utils/install.js.map +1 -0
- package/dist/utils/package-manager.d.ts +11 -0
- package/dist/utils/package-manager.d.ts.map +1 -0
- package/dist/utils/package-manager.js +44 -0
- package/dist/utils/package-manager.js.map +1 -0
- package/dist/utils/path.d.ts +15 -0
- package/dist/utils/path.d.ts.map +1 -0
- package/dist/utils/path.js +20 -0
- package/dist/utils/path.js.map +1 -0
- package/dist/utils/workspace.d.ts +13 -0
- package/dist/utils/workspace.d.ts.map +1 -0
- package/dist/utils/workspace.js +79 -0
- package/dist/utils/workspace.js.map +1 -0
- package/hegel-vscode/hegel-companion-1.0.9.vsix +0 -0
- package/hegel.config.schema.json +0 -5
- package/package.json +1 -1
- package/hegel-vscode/hegel-companion-1.0.7.vsix +0 -0
package/dist/setup.js
CHANGED
|
@@ -1,89 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
1. \`hegel-status\`: Fetch the real-time health status of the current session.
|
|
17
|
-
2. \`hegel-review\`: Fetch a comprehensive retrospective of the session.
|
|
18
|
-
|
|
19
|
-
## When to use these tools
|
|
20
|
-
|
|
21
|
-
- **Proactive Self-Correction**: If the user seems frustrated, repeats the same prompt, or if you feel the conversation is losing focus, call \`hegel-status\` to check if Hegel has flagged \`context-drift\` or \`prompt-degradation\`. Adjust your behavior accordingly.
|
|
22
|
-
- **End-of-Task Summaries**: When completing a significant multi-step task, call \`hegel-review\` and append a brief session health summary to your final response.
|
|
23
|
-
- **Transparency**: If Hegel blocks a prompt or flags a critical concern, acknowledge it and help the user formulate a better prompt.
|
|
24
|
-
`;
|
|
25
|
-
const defaultDeps = {
|
|
26
|
-
loadConfig,
|
|
27
|
-
writeHooksFile,
|
|
28
|
-
mkdir,
|
|
29
|
-
writeFile,
|
|
30
|
-
readdir,
|
|
31
|
-
readFile,
|
|
32
|
-
access,
|
|
33
|
-
rm,
|
|
34
|
-
execFileSync,
|
|
35
|
-
log: console.log,
|
|
36
|
-
error: console.error,
|
|
37
|
-
resolveHegelRoot: () => resolve(join(dirname(fileURLToPath(import.meta.url)), "..")),
|
|
38
|
-
platform: process.platform,
|
|
39
|
-
env: process.env,
|
|
40
|
-
};
|
|
41
|
-
/**
|
|
42
|
-
* Walks up from `startDir` (exclusive) looking for an ancestor that already
|
|
43
|
-
* contains a Hegel install (hegel.config.json + .cursor/hooks.json).
|
|
44
|
-
* Returns the ancestor path if found, or null. Stops at the filesystem root.
|
|
45
|
-
*/
|
|
46
|
-
export async function findAncestorHegelInstall(startDir, accessFn = access) {
|
|
47
|
-
const root = parse(startDir).root;
|
|
48
|
-
let current = dirname(startDir);
|
|
49
|
-
while (current && current !== root) {
|
|
50
|
-
try {
|
|
51
|
-
await accessFn(join(current, "hegel.config.json"));
|
|
52
|
-
await accessFn(join(current, ".cursor", "hooks.json"));
|
|
53
|
-
return current;
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
// keep walking up
|
|
57
|
-
}
|
|
58
|
-
const parent = dirname(current);
|
|
59
|
-
if (parent === current)
|
|
60
|
-
break;
|
|
61
|
-
current = parent;
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { configHash } from "./hooks-generator.js";
|
|
4
|
+
// Re-export shared types/utils for tests and downstream usage
|
|
5
|
+
export * from "./commands/types.js";
|
|
6
|
+
export * from "./utils/workspace.js";
|
|
7
|
+
export * from "./utils/install.js";
|
|
8
|
+
export * from "./utils/package-manager.js";
|
|
9
|
+
export * from "./utils/path.js";
|
|
10
|
+
// Import commands
|
|
11
|
+
import { runInit } from "./commands/init.js";
|
|
12
|
+
import { runUpdate } from "./commands/update.js";
|
|
13
|
+
import { runCleanup } from "./commands/cleanup.js";
|
|
14
|
+
import { defaultDeps } from "./commands/types.js";
|
|
15
|
+
export { runUpdate, runCleanup };
|
|
65
16
|
export async function runSetup(argv = process.argv, deps = defaultDeps) {
|
|
66
17
|
const rest = argv.slice(2);
|
|
67
|
-
const forceFlag = rest.includes("--force");
|
|
68
18
|
const positional = rest.filter((a) => !a.startsWith("--"));
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (positional[0] === "update") {
|
|
19
|
+
const command = positional[0];
|
|
20
|
+
if (command === "update") {
|
|
72
21
|
return runUpdate(argv, deps);
|
|
73
22
|
}
|
|
74
|
-
if (
|
|
23
|
+
if (command === "cleanup") {
|
|
24
|
+
return runCleanup(argv, deps);
|
|
25
|
+
}
|
|
26
|
+
if (command === "init" && positional[1] === "update") {
|
|
75
27
|
deps.error("Error: 'update' is a command, not a project path.");
|
|
76
28
|
deps.error(" If your installed Hegel version does not know `update`, upgrade first:");
|
|
77
29
|
deps.error(" npm install @hegel-dev/companion@latest");
|
|
78
30
|
deps.error(" npx hegel-companion update .");
|
|
79
31
|
return 1;
|
|
80
32
|
}
|
|
81
|
-
let targetDir =
|
|
33
|
+
let targetDir = command;
|
|
82
34
|
if (targetDir === "init" && positional[1]) {
|
|
83
35
|
targetDir = positional[1];
|
|
84
36
|
}
|
|
85
|
-
// Catch the common typo `npx hegel-companion init init` — argv[3]==="init"
|
|
86
|
-
// was being treated as the target path, scaffolding into ./init/.
|
|
87
37
|
if (targetDir === "init") {
|
|
88
38
|
deps.error("Error: 'init' is not a valid project path.");
|
|
89
39
|
deps.error(" Did you mean: npx hegel-companion init .");
|
|
@@ -96,6 +46,7 @@ export async function runSetup(argv = process.argv, deps = defaultDeps) {
|
|
|
96
46
|
deps.log(" init <project-path> Scaffold .cursor/hooks.json + register MCP + install VSIX");
|
|
97
47
|
deps.log(" update [project-path] Reinstall @hegel-dev/companion@latest, refresh config,");
|
|
98
48
|
deps.log(" prune orphan dirs (default path: .)");
|
|
49
|
+
deps.log(" cleanup [project-path] Prune stale concerns and recompute session states");
|
|
99
50
|
deps.log("");
|
|
100
51
|
deps.log("Use '.' for the current directory. Pass --force on init to bypass safety checks.");
|
|
101
52
|
deps.log("Pass --skip-npm on update to skip the dependency upgrade step.");
|
|
@@ -106,485 +57,10 @@ export async function runSetup(argv = process.argv, deps = defaultDeps) {
|
|
|
106
57
|
deps.log(` enableLlmAnalysis: ${config.enableLlmAnalysis}`);
|
|
107
58
|
deps.log(` timeoutSeconds: ${config.timeoutSeconds}s`);
|
|
108
59
|
deps.log(` strictness: ${config.strictness}`);
|
|
109
|
-
deps.log(` observeOnly: ${config.observeOnly}`);
|
|
110
60
|
deps.log(` configHash: ${configHash(config)}`);
|
|
111
61
|
return 1;
|
|
112
62
|
}
|
|
113
|
-
|
|
114
|
-
// Refuse to nest a Hegel install inside another one. This catches e.g.
|
|
115
|
-
// `init hegel-mcp` being run from a workspace that already has Hegel set up,
|
|
116
|
-
// which would silently create an orphan .cursor/ and hegel.config.json that
|
|
117
|
-
// Cursor never reads.
|
|
118
|
-
const ancestor = await findAncestorHegelInstall(projectPath, deps.access);
|
|
119
|
-
if (ancestor && !forceFlag) {
|
|
120
|
-
deps.error(`Error: ${ancestor} already has a Hegel install.`);
|
|
121
|
-
deps.error(` Refusing to scaffold a nested install at ${projectPath}.`);
|
|
122
|
-
deps.error(` If this is intentional, rerun with --force.`);
|
|
123
|
-
return 1;
|
|
124
|
-
}
|
|
125
|
-
const config = await deps.loadConfig(projectPath);
|
|
126
|
-
const hooksFile = join(projectPath, ".cursor", "hooks.json");
|
|
127
|
-
const hegelRoot = deps.resolveHegelRoot();
|
|
128
|
-
// Source-repo mode: when `init` targets the Hegel repo itself, Cursor's
|
|
129
|
-
// MCP server lives at `<hegelRoot>/dist/mcp.js`, not at
|
|
130
|
-
// `node_modules/@hegel-dev/companion/dist/mcp.js`. Detect this and emit an
|
|
131
|
-
// absolute local path so running the CLI inside the source repo doesn't
|
|
132
|
-
// silently break the MCP config.
|
|
133
|
-
const isSourceRepo = resolve(projectPath) === resolve(hegelRoot);
|
|
134
|
-
// Pre-flight report: if an existing install is present, tell the user what
|
|
135
|
-
// we're about to touch before doing it. Without --force, a matching config
|
|
136
|
-
// hash makes hooks.json a no-op (see writeHooksFile); this prevents
|
|
137
|
-
// accidental rewrites that change `generatedAt` for no functional reason.
|
|
138
|
-
const existingHooks = await deps.readFile(hooksFile, "utf-8").catch(() => null);
|
|
139
|
-
if (existingHooks) {
|
|
140
|
-
deps.log(`Existing Hegel install detected at ${projectPath}.`);
|
|
141
|
-
if (isSourceRepo) {
|
|
142
|
-
deps.log(" (running against the Hegel source repo — will use local dist/ paths)");
|
|
143
|
-
}
|
|
144
|
-
if (!forceFlag) {
|
|
145
|
-
deps.log(" hooks.json will be rewritten only if the config hash differs.");
|
|
146
|
-
deps.log(" Pass --force to rewrite unconditionally.");
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
const hooksWritten = await deps.writeHooksFile(projectPath, config, forceFlag);
|
|
150
|
-
if (!hooksWritten && existingHooks) {
|
|
151
|
-
deps.log(`hooks.json is already up to date (config hash ${configHash(config)})`);
|
|
152
|
-
}
|
|
153
|
-
// Write the Hegel Companion rule
|
|
154
|
-
const rulesDir = join(projectPath, ".cursor", "rules");
|
|
155
|
-
await deps.mkdir(rulesDir, { recursive: true });
|
|
156
|
-
await deps.writeFile(join(rulesDir, "hegel-companion.mdc"), COMPANION_RULE, "utf-8");
|
|
157
|
-
// Scaffold hegel.config.json if it doesn't exist
|
|
158
|
-
const configPath = join(projectPath, "hegel.config.json");
|
|
159
|
-
try {
|
|
160
|
-
const existingConfig = await deps.readFile(configPath, "utf-8").catch(() => null);
|
|
161
|
-
if (!existingConfig) {
|
|
162
|
-
const defaultConfig = {
|
|
163
|
-
"$schema": isSourceRepo
|
|
164
|
-
? "./hegel.config.schema.json"
|
|
165
|
-
: "./node_modules/@hegel-dev/companion/hegel.config.schema.json",
|
|
166
|
-
"model": "auto",
|
|
167
|
-
"enableLlmAnalysis": true,
|
|
168
|
-
"timeoutSeconds": 15,
|
|
169
|
-
"strictness": "balanced",
|
|
170
|
-
"observeOnly": true
|
|
171
|
-
};
|
|
172
|
-
await deps.writeFile(configPath, JSON.stringify(defaultConfig, null, 2) + "\n", "utf-8");
|
|
173
|
-
deps.log(`✅ Default hegel.config.json created`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
// Ignore
|
|
178
|
-
}
|
|
179
|
-
// Register MCP server in .cursor/mcp.json
|
|
180
|
-
const mcpConfigPath = join(projectPath, ".cursor", "mcp.json");
|
|
181
|
-
try {
|
|
182
|
-
let mcpConfig = { mcpServers: {} };
|
|
183
|
-
const existingMcp = await deps.readFile(mcpConfigPath, "utf-8").catch(() => null);
|
|
184
|
-
if (existingMcp) {
|
|
185
|
-
mcpConfig = JSON.parse(existingMcp);
|
|
186
|
-
if (!mcpConfig.mcpServers)
|
|
187
|
-
mcpConfig.mcpServers = {};
|
|
188
|
-
}
|
|
189
|
-
// We use a direct node command rather than npx because Cursor's internal
|
|
190
|
-
// MCP spawner on Windows often fails to pass environment variables or
|
|
191
|
-
// trailing arguments correctly through npx. In source-repo mode we point
|
|
192
|
-
// at the local build; in consumer mode, at the node_modules install.
|
|
193
|
-
const mcpArgs = isSourceRepo
|
|
194
|
-
? [join(hegelRoot, "dist", "mcp.js").replace(/\\/g, "/")]
|
|
195
|
-
: ["node_modules/@hegel-dev/companion/dist/mcp.js"];
|
|
196
|
-
mcpConfig.mcpServers["hegel-mcp"] = {
|
|
197
|
-
command: "node",
|
|
198
|
-
args: mcpArgs,
|
|
199
|
-
env: {
|
|
200
|
-
"HEGEL_WORKSPACE_ROOT": projectPath
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
await deps.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
|
|
204
|
-
deps.log(`✅ MCP server registered in .cursor/mcp.json`);
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
deps.log("Failed to register MCP server in .cursor/mcp.json");
|
|
208
|
-
}
|
|
209
|
-
// Install the VS Code extension (or report failure loudly at the end).
|
|
210
|
-
const vscodeDir = join(hegelRoot, "hegel-vscode");
|
|
211
|
-
const extensionResult = await installVsCodeExtension(vscodeDir, deps);
|
|
212
|
-
const model = config.model === "auto" ? "Cursor default" : config.model;
|
|
213
|
-
const mode = config.observeOnly ? "observe-only (silent — full analysis, no chat intervention)" : "active (will block on concerns)";
|
|
214
|
-
deps.log("");
|
|
215
|
-
deps.log(`✅ Hegel hooks written to ${hooksFile}`);
|
|
216
|
-
deps.log(`✅ Hegel Companion Rule written to ${join(rulesDir, "hegel-companion.mdc")}`);
|
|
217
|
-
deps.log(` Config hash: ${configHash(config)}`);
|
|
218
|
-
deps.log("");
|
|
219
|
-
deps.log(`Mode: ${mode}`);
|
|
220
|
-
deps.log("");
|
|
221
|
-
deps.log("Layers:");
|
|
222
|
-
deps.log(` Layer 1: Rule-based analysis (command hooks) — ${config.observeOnly ? "full analysis, no chat intervention" : "blocks on warning+"}`);
|
|
223
|
-
if (config.enableLlmAnalysis) {
|
|
224
|
-
deps.log(` Layer 2: LLM deep analysis (prompt hooks) — model: ${model} — ${config.observeOnly ? "full analysis, no chat intervention" : "blocks on concern"}`);
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
deps.log(" Layer 2: LLM deep analysis — disabled (enableLlmAnalysis=false)");
|
|
228
|
-
}
|
|
229
|
-
if (config.observeOnly) {
|
|
230
|
-
deps.log("");
|
|
231
|
-
deps.log(" observeOnly is ON — both layers run full analysis and collect stats,");
|
|
232
|
-
deps.log(" but the user never sees intervention in the chat.");
|
|
233
|
-
deps.log(" Set observeOnly=false in hegel.config.json to enable blocking.");
|
|
234
|
-
}
|
|
235
|
-
deps.log("");
|
|
236
|
-
deps.log("Hot-reload: config changes are auto-detected on the next prompt.");
|
|
237
|
-
deps.log(" Change hegel.config.json → hooks.json is regenerated on the next");
|
|
238
|
-
deps.log(" beforeSubmitPrompt hook. Model changes on Layer 2 prompt hooks");
|
|
239
|
-
deps.log(" usually require a full Cursor restart to take effect.");
|
|
240
|
-
// Extension-install status goes at the very end of output so it is the last
|
|
241
|
-
// thing the user sees. Silent mid-flow failure (fixed in 1.0.3) had been
|
|
242
|
-
// biting users who missed the one-liner buried in init's other output.
|
|
243
|
-
if (extensionResult.status !== "installed") {
|
|
244
|
-
deps.log("");
|
|
245
|
-
deps.log("⚠️ VS Code extension was NOT installed automatically.");
|
|
246
|
-
if (extensionResult.status === "vsix-not-found") {
|
|
247
|
-
deps.log(` Reason: ${extensionResult.reason}`);
|
|
248
|
-
deps.log(" The Hegel sidebar won't appear until a VSIX is built/installed.");
|
|
249
|
-
}
|
|
250
|
-
else if (extensionResult.status === "cursor-cli-missing") {
|
|
251
|
-
deps.log(` Reason: ${extensionResult.reason ?? "'cursor' CLI not found on PATH"}`);
|
|
252
|
-
deps.log(" Run this from a shell where the 'cursor' command is available");
|
|
253
|
-
deps.log(" (e.g. Cursor's built-in terminal, or with Cursor on your PATH):");
|
|
254
|
-
deps.log(` cursor --install-extension "${extensionResult.vsixPath}"`);
|
|
255
|
-
deps.log(" Then fully quit and reopen Cursor (not just Reload Window — the");
|
|
256
|
-
deps.log(" extension manifest is cached in the extension-host process).");
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
deps.log(` Reason: ${extensionResult.reason ?? "unknown"}`);
|
|
260
|
-
deps.log(" You can retry manually:");
|
|
261
|
-
deps.log(` cursor --install-extension "${extensionResult.vsixPath}"`);
|
|
262
|
-
deps.log(" Then fully quit and reopen Cursor (not just Reload Window).");
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return 0;
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Well-known absolute paths where Cursor installs the `cursor` CLI shim.
|
|
269
|
-
* Returned in priority order — first existing candidate wins. We intentionally
|
|
270
|
-
* probe only well-documented install locations: user-scoped first (most
|
|
271
|
-
* common), then system-scoped.
|
|
272
|
-
*
|
|
273
|
-
* Windows candidates end in .cmd because Node's execFileSync needs the shim,
|
|
274
|
-
* not the Electron binary (Cursor.exe) which would try to launch a second IDE
|
|
275
|
-
* window rather than run the CLI.
|
|
276
|
-
*/
|
|
277
|
-
export function getCursorCliCandidates(platform = process.platform, env = process.env) {
|
|
278
|
-
const candidates = [];
|
|
279
|
-
// Use platform-specific path joiners so candidate paths are correct even
|
|
280
|
-
// when the host OS differs (e.g. when Node is run under WSL, or in tests).
|
|
281
|
-
const pj = platform === "win32" ? win32Path.join : posixPath.join;
|
|
282
|
-
if (platform === "win32") {
|
|
283
|
-
const local = env.LOCALAPPDATA;
|
|
284
|
-
if (local) {
|
|
285
|
-
candidates.push(pj(local, "Programs", "cursor", "resources", "app", "bin", "cursor.cmd"));
|
|
286
|
-
candidates.push(pj(local, "Programs", "Cursor", "resources", "app", "bin", "cursor.cmd"));
|
|
287
|
-
}
|
|
288
|
-
const programFiles = env["ProgramFiles"];
|
|
289
|
-
if (programFiles) {
|
|
290
|
-
candidates.push(pj(programFiles, "Cursor", "resources", "app", "bin", "cursor.cmd"));
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
else if (platform === "darwin") {
|
|
294
|
-
candidates.push("/Applications/Cursor.app/Contents/Resources/app/bin/cursor");
|
|
295
|
-
const home = env.HOME;
|
|
296
|
-
if (home) {
|
|
297
|
-
candidates.push(pj(home, "Applications", "Cursor.app", "Contents", "Resources", "app", "bin", "cursor"));
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
candidates.push("/usr/local/bin/cursor");
|
|
302
|
-
candidates.push("/usr/bin/cursor");
|
|
303
|
-
const home = env.HOME;
|
|
304
|
-
if (home) {
|
|
305
|
-
candidates.push(pj(home, ".local", "share", "cursor", "bin", "cursor"));
|
|
306
|
-
candidates.push(pj(home, ".cursor", "bin", "cursor"));
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return candidates;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Quote one argument for cmd.exe. The current call sites pass command names,
|
|
313
|
-
* static flags, package names, and filesystem paths; still, centralizing the
|
|
314
|
-
* quoting keeps spaces in VSIX paths and Program Files installs safe.
|
|
315
|
-
*/
|
|
316
|
-
function quoteWindowsCmdArg(arg) {
|
|
317
|
-
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
318
|
-
}
|
|
319
|
-
function isBareWindowsCommand(command) {
|
|
320
|
-
return !/[\\/\s]/.test(command);
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Run a command synchronously, using cmd.exe explicitly on Windows.
|
|
324
|
-
*
|
|
325
|
-
* Node deprecated `execFileSync(command, args, { shell: true })` because args
|
|
326
|
-
* are concatenated without escaping (DEP0190). We still need cmd.exe on
|
|
327
|
-
* Windows so PATHEXT resolves `npm`/`cursor` to their `.cmd` shims, but we
|
|
328
|
-
* invoke it directly with one quoted command string instead of shell:true.
|
|
329
|
-
*/
|
|
330
|
-
function execCommandSync(command, args, deps, extra = {}) {
|
|
331
|
-
const platform = deps.platform ?? process.platform;
|
|
332
|
-
if (platform !== "win32") {
|
|
333
|
-
deps.execFileSync(command, args, extra);
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
const quotedArgs = args.map(quoteWindowsCmdArg).join(" ");
|
|
337
|
-
const commandLine = isBareWindowsCommand(command)
|
|
338
|
-
? [command, quotedArgs].filter(Boolean).join(" ")
|
|
339
|
-
: `"${[quoteWindowsCmdArg(command), quotedArgs].filter(Boolean).join(" ")}"`;
|
|
340
|
-
const windowsOptions = {
|
|
341
|
-
...extra,
|
|
342
|
-
windowsVerbatimArguments: true,
|
|
343
|
-
};
|
|
344
|
-
deps.execFileSync("cmd.exe", ["/d", "/s", "/c", commandLine], windowsOptions);
|
|
345
|
-
}
|
|
346
|
-
/**
|
|
347
|
-
* Walk the candidate list and return the first one that exists on disk.
|
|
348
|
-
* Used as a fallback when `execFileSync("cursor", ...)` throws ENOENT — the
|
|
349
|
-
* most common failure mode for users running `npx hegel-companion init` from
|
|
350
|
-
* a non-Cursor terminal (e.g. stock PowerShell/cmd without Cursor on PATH).
|
|
351
|
-
*/
|
|
352
|
-
async function findInstalledCursorCli(candidates, accessFn) {
|
|
353
|
-
for (const candidate of candidates) {
|
|
354
|
-
try {
|
|
355
|
-
await accessFn(candidate);
|
|
356
|
-
return candidate;
|
|
357
|
-
}
|
|
358
|
-
catch {
|
|
359
|
-
// try next
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return null;
|
|
363
|
-
}
|
|
364
|
-
export async function installVsCodeExtension(vscodeDir, deps) {
|
|
365
|
-
let files;
|
|
366
|
-
try {
|
|
367
|
-
files = await deps.readdir(vscodeDir);
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
return { status: "vsix-not-found", reason: `${vscodeDir} not readable` };
|
|
371
|
-
}
|
|
372
|
-
const vsixFile = files.find((f) => f.endsWith(".vsix"));
|
|
373
|
-
if (!vsixFile) {
|
|
374
|
-
return { status: "vsix-not-found", reason: `no .vsix file found in ${vscodeDir}` };
|
|
375
|
-
}
|
|
376
|
-
const vsixPath = join(vscodeDir, vsixFile);
|
|
377
|
-
deps.log(`Installing VS Code extension: ${vsixFile}...`);
|
|
378
|
-
// First attempt: `cursor` on PATH. This is the common case when the user
|
|
379
|
-
// runs `init` from Cursor's built-in terminal or from any shell that has
|
|
380
|
-
// Cursor's bin directory on PATH. On Windows this goes through cmd.exe so
|
|
381
|
-
// PATHEXT can resolve `cursor` to cursor.cmd.
|
|
382
|
-
try {
|
|
383
|
-
execCommandSync("cursor", ["--install-extension", vsixPath], deps, { stdio: "inherit" });
|
|
384
|
-
return { status: "installed", vsixPath };
|
|
385
|
-
}
|
|
386
|
-
catch (err) {
|
|
387
|
-
const errno = err?.code;
|
|
388
|
-
if (errno !== "ENOENT") {
|
|
389
|
-
// Non-ENOENT means `cursor` was found but rejected the install — no
|
|
390
|
-
// point retrying with an absolute path, the error is genuine.
|
|
391
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
392
|
-
return { status: "install-failed", vsixPath, reason: message };
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
// Second attempt: auto-discovered absolute path. Eliminates the manual
|
|
396
|
-
// "please run from Cursor's built-in terminal" workaround for users whose
|
|
397
|
-
// shell simply doesn't have Cursor on PATH.
|
|
398
|
-
const candidates = getCursorCliCandidates(deps.platform, deps.env);
|
|
399
|
-
const discovered = await findInstalledCursorCli(candidates, deps.access);
|
|
400
|
-
if (!discovered) {
|
|
401
|
-
return {
|
|
402
|
-
status: "cursor-cli-missing",
|
|
403
|
-
vsixPath,
|
|
404
|
-
reason: `'cursor' CLI not found on PATH and no install detected at known locations (${candidates.length} path${candidates.length === 1 ? "" : "s"} checked)`,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
deps.log(` 'cursor' not on PATH — found install at ${discovered}, retrying...`);
|
|
408
|
-
try {
|
|
409
|
-
execCommandSync(discovered, ["--install-extension", vsixPath], deps, { stdio: "inherit" });
|
|
410
|
-
return { status: "installed", vsixPath, usedCursorPath: discovered };
|
|
411
|
-
}
|
|
412
|
-
catch (err) {
|
|
413
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
414
|
-
return { status: "install-failed", vsixPath, reason: `retry via ${discovered} failed: ${message}` };
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* Marker filenames inside an orphan dir that indicate it was once a Hegel
|
|
419
|
-
* install (created by a CLI mistake like `init hegel-mcp` or `init init`).
|
|
420
|
-
* Pruning is gated on at least one of these existing — never blindly removes
|
|
421
|
-
* a directory that happens to be named `init` or `hegel-mcp`.
|
|
422
|
-
*/
|
|
423
|
-
const ORPHAN_DIR_NAMES = ["hegel-mcp", "init"];
|
|
424
|
-
const ORPHAN_MARKER_PATHS = ["hegel.config.json", join(".cursor", "hooks.json")];
|
|
425
|
-
/**
|
|
426
|
-
* Removes orphan Hegel installs left behind by past CLI mistakes
|
|
427
|
-
* (`init hegel-mcp`, `init init`). Only removes a candidate dir if it
|
|
428
|
-
* contains a Hegel marker file — never deletes user code directories that
|
|
429
|
-
* happen to share the name.
|
|
430
|
-
*/
|
|
431
|
-
export async function pruneOrphanInstalls(projectPath, deps) {
|
|
432
|
-
const result = { pruned: [], skipped: [] };
|
|
433
|
-
for (const name of ORPHAN_DIR_NAMES) {
|
|
434
|
-
const dir = join(projectPath, name);
|
|
435
|
-
try {
|
|
436
|
-
await deps.access(dir);
|
|
437
|
-
}
|
|
438
|
-
catch {
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
let hasMarker = false;
|
|
442
|
-
for (const marker of ORPHAN_MARKER_PATHS) {
|
|
443
|
-
try {
|
|
444
|
-
await deps.access(join(dir, marker));
|
|
445
|
-
hasMarker = true;
|
|
446
|
-
break;
|
|
447
|
-
}
|
|
448
|
-
catch {
|
|
449
|
-
// try next marker
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
if (!hasMarker) {
|
|
453
|
-
result.skipped.push({ path: dir, reason: "no Hegel marker file detected (kept)" });
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
-
try {
|
|
457
|
-
await deps.rm(dir, { recursive: true, force: true });
|
|
458
|
-
result.pruned.push(dir);
|
|
459
|
-
deps.log(` Pruned orphan install at ${dir}`);
|
|
460
|
-
}
|
|
461
|
-
catch (err) {
|
|
462
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
463
|
-
result.skipped.push({ path: dir, reason: `rm failed: ${message}` });
|
|
464
|
-
deps.log(` Could not prune ${dir}: ${message}`);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
return result;
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* Reads the version of @hegel-dev/companion from the consumer's
|
|
471
|
-
* node_modules/, returning null if not installed (e.g. source-repo mode).
|
|
472
|
-
*/
|
|
473
|
-
export async function readInstalledCompanionVersion(projectPath, readFileFn = readFile) {
|
|
474
|
-
try {
|
|
475
|
-
const pkgPath = join(projectPath, "node_modules", "@hegel-dev", "companion", "package.json");
|
|
476
|
-
const contents = await readFileFn(pkgPath, "utf-8");
|
|
477
|
-
const parsed = JSON.parse(contents);
|
|
478
|
-
return typeof parsed.version === "string" ? parsed.version : null;
|
|
479
|
-
}
|
|
480
|
-
catch {
|
|
481
|
-
return null;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* `update` subcommand. Single-step refresh path for downstream projects:
|
|
486
|
-
* 1. Refuses if no Hegel install present (hint to use `init`).
|
|
487
|
-
* 2. Detects source-repo mode and skips npm install in that case.
|
|
488
|
-
* 3. Runs `npm install @hegel-dev/companion@latest` (skippable via --skip-npm).
|
|
489
|
-
* 4. Re-runs setup with --force so hooks.json regenerates, MCP re-registers,
|
|
490
|
-
* and the bundled VSIX is reinstalled (with 1.0.4 auto-discovery).
|
|
491
|
-
* 5. Prunes orphan `hegel-mcp/` and `init/` directories left over from past
|
|
492
|
-
* CLI mistakes (only when those dirs contain a Hegel marker file).
|
|
493
|
-
* 6. Prints the new installed version + the Cursor full-quit reminder.
|
|
494
|
-
*/
|
|
495
|
-
export async function runUpdate(argv = process.argv, deps = defaultDeps) {
|
|
496
|
-
const rest = argv.slice(2);
|
|
497
|
-
const positional = rest.filter((a) => !a.startsWith("--"));
|
|
498
|
-
// positional[0] === "update"; positional[1] is the optional path
|
|
499
|
-
const targetDir = positional[1] ?? ".";
|
|
500
|
-
const skipNpm = rest.includes("--skip-npm");
|
|
501
|
-
const projectPath = resolve(targetDir);
|
|
502
|
-
// Step 1: sanity check — must be an existing Hegel install.
|
|
503
|
-
const hasConfig = await deps.access(join(projectPath, "hegel.config.json")).then(() => true).catch(() => false);
|
|
504
|
-
const hasHooks = await deps.access(join(projectPath, ".cursor", "hooks.json")).then(() => true).catch(() => false);
|
|
505
|
-
if (!hasConfig && !hasHooks) {
|
|
506
|
-
deps.error(`Error: no Hegel install detected at ${projectPath}.`);
|
|
507
|
-
deps.error(` Run 'npx hegel-companion init ${targetDir}' first.`);
|
|
508
|
-
return 1;
|
|
509
|
-
}
|
|
510
|
-
deps.log(`Updating Hegel install at ${projectPath}`);
|
|
511
|
-
deps.log("");
|
|
512
|
-
// Step 2: source-repo detection. Running `update` against the Hegel source
|
|
513
|
-
// repo itself must NOT npm-install the package (would clobber the live
|
|
514
|
-
// dev tree); just refresh hooks via init --force.
|
|
515
|
-
const hegelRoot = deps.resolveHegelRoot();
|
|
516
|
-
const isSourceRepo = resolve(projectPath) === resolve(hegelRoot);
|
|
517
|
-
const beforeVersion = await readInstalledCompanionVersion(projectPath, deps.readFile);
|
|
518
|
-
// Step 3: npm install @hegel-dev/companion@latest
|
|
519
|
-
if (isSourceRepo) {
|
|
520
|
-
deps.log("Source-repo mode detected — skipping `npm install @hegel-dev/companion@latest`.");
|
|
521
|
-
deps.log("");
|
|
522
|
-
}
|
|
523
|
-
else if (skipNpm) {
|
|
524
|
-
deps.log("--skip-npm passed — skipping dependency upgrade.");
|
|
525
|
-
deps.log("");
|
|
526
|
-
}
|
|
527
|
-
else {
|
|
528
|
-
deps.log("Running: npm install @hegel-dev/companion@latest");
|
|
529
|
-
try {
|
|
530
|
-
// Windows ships npm as npm.cmd/npm.ps1; execFileSync won't launch those
|
|
531
|
-
// directly. execCommandSync uses cmd.exe on Windows so PATHEXT resolution
|
|
532
|
-
// matches the user's interactive shell without relying on shell:true.
|
|
533
|
-
execCommandSync("npm", ["install", "@hegel-dev/companion@latest"], deps, { stdio: "inherit", cwd: projectPath });
|
|
534
|
-
}
|
|
535
|
-
catch (err) {
|
|
536
|
-
const errno = err?.code;
|
|
537
|
-
if (errno === "ENOENT") {
|
|
538
|
-
deps.error("Error: 'npm' not found on PATH.");
|
|
539
|
-
deps.error(" Install Node.js (which bundles npm) and rerun, or pass --skip-npm");
|
|
540
|
-
deps.error(" to refresh hooks/VSIX without touching dependencies.");
|
|
541
|
-
return 1;
|
|
542
|
-
}
|
|
543
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
544
|
-
deps.error(`Error: npm install failed: ${message}`);
|
|
545
|
-
return 1;
|
|
546
|
-
}
|
|
547
|
-
deps.log("");
|
|
548
|
-
}
|
|
549
|
-
// Step 4: re-run setup with --force in the same process. Logs are
|
|
550
|
-
// re-emitted by runSetup itself, so the user sees a continuous stream.
|
|
551
|
-
const setupArgv = ["node", argv[1] ?? "setup.js", projectPath, "--force"];
|
|
552
|
-
const setupExit = await runSetup(setupArgv, deps);
|
|
553
|
-
if (setupExit !== 0) {
|
|
554
|
-
deps.error(`Error: setup re-run failed with exit code ${setupExit}.`);
|
|
555
|
-
return setupExit;
|
|
556
|
-
}
|
|
557
|
-
// Step 5: prune orphan installs.
|
|
558
|
-
deps.log("");
|
|
559
|
-
deps.log("Checking for orphan Hegel directories...");
|
|
560
|
-
const prune = await pruneOrphanInstalls(projectPath, deps);
|
|
561
|
-
if (prune.pruned.length === 0 && prune.skipped.length === 0) {
|
|
562
|
-
deps.log(" No orphans found.");
|
|
563
|
-
}
|
|
564
|
-
// Step 6: report the final installed version.
|
|
565
|
-
deps.log("");
|
|
566
|
-
const afterVersion = await readInstalledCompanionVersion(projectPath, deps.readFile);
|
|
567
|
-
if (afterVersion) {
|
|
568
|
-
if (beforeVersion && beforeVersion !== afterVersion) {
|
|
569
|
-
deps.log(`✅ Updated @hegel-dev/companion: ${beforeVersion} → ${afterVersion}`);
|
|
570
|
-
}
|
|
571
|
-
else if (beforeVersion === afterVersion) {
|
|
572
|
-
deps.log(`✅ @hegel-dev/companion ${afterVersion} (already at latest — refreshed hooks + VSIX)`);
|
|
573
|
-
}
|
|
574
|
-
else {
|
|
575
|
-
deps.log(`✅ @hegel-dev/companion ${afterVersion} installed.`);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
else if (isSourceRepo) {
|
|
579
|
-
deps.log("✅ Source-repo update complete (no node_modules version to report).");
|
|
580
|
-
}
|
|
581
|
-
else {
|
|
582
|
-
deps.log("✅ Update complete (could not read installed version from node_modules).");
|
|
583
|
-
}
|
|
584
|
-
deps.log("");
|
|
585
|
-
deps.log("Reminder: fully quit and reopen Cursor (not just Reload Window) so the");
|
|
586
|
-
deps.log(" updated VSIX manifest is picked up by the extension host.");
|
|
587
|
-
return 0;
|
|
63
|
+
return runInit(argv, deps);
|
|
588
64
|
}
|
|
589
65
|
function isEntrypoint() {
|
|
590
66
|
return !process.env.VITEST &&
|
|
@@ -592,7 +68,7 @@ function isEntrypoint() {
|
|
|
592
68
|
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
593
69
|
}
|
|
594
70
|
if (isEntrypoint()) {
|
|
595
|
-
runSetup().then((exitCode) => {
|
|
71
|
+
runSetup(process.argv, defaultDeps).then((exitCode) => {
|
|
596
72
|
if (exitCode !== 0)
|
|
597
73
|
process.exit(exitCode);
|
|
598
74
|
}).catch((err) => {
|