@hegel-dev/companion 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +15 -2
  2. package/dist/commands/cleanup.d.ts +3 -0
  3. package/dist/commands/cleanup.d.ts.map +1 -0
  4. package/dist/commands/cleanup.js +51 -0
  5. package/dist/commands/cleanup.js.map +1 -0
  6. package/dist/commands/init.d.ts +3 -0
  7. package/dist/commands/init.d.ts.map +1 -0
  8. package/dist/commands/init.js +142 -0
  9. package/dist/commands/init.js.map +1 -0
  10. package/dist/commands/types.d.ts +22 -0
  11. package/dist/commands/types.d.ts.map +1 -0
  12. package/dist/commands/types.js +23 -0
  13. package/dist/commands/types.js.map +1 -0
  14. package/dist/commands/update.d.ts +3 -0
  15. package/dist/commands/update.d.ts.map +1 -0
  16. package/dist/commands/update.js +90 -0
  17. package/dist/commands/update.js.map +1 -0
  18. package/dist/config.d.ts +0 -2
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +0 -1
  21. package/dist/config.js.map +1 -1
  22. package/dist/hook.d.ts +0 -17
  23. package/dist/hook.d.ts.map +1 -1
  24. package/dist/hook.js +3 -26
  25. package/dist/hook.js.map +1 -1
  26. package/dist/hooks-generator.d.ts +0 -4
  27. package/dist/hooks-generator.d.ts.map +1 -1
  28. package/dist/hooks-generator.js +2 -7
  29. package/dist/hooks-generator.js.map +1 -1
  30. package/dist/prompts.d.ts +2 -2
  31. package/dist/prompts.d.ts.map +1 -1
  32. package/dist/prompts.js +4 -24
  33. package/dist/prompts.js.map +1 -1
  34. package/dist/setup.d.ts +9 -90
  35. package/dist/setup.d.ts.map +1 -1
  36. package/dist/setup.js +24 -548
  37. package/dist/setup.js.map +1 -1
  38. package/dist/utils/install.d.ts +13 -0
  39. package/dist/utils/install.d.ts.map +1 -0
  40. package/dist/utils/install.js +112 -0
  41. package/dist/utils/install.js.map +1 -0
  42. package/dist/utils/package-manager.d.ts +11 -0
  43. package/dist/utils/package-manager.d.ts.map +1 -0
  44. package/dist/utils/package-manager.js +44 -0
  45. package/dist/utils/package-manager.js.map +1 -0
  46. package/dist/utils/path.d.ts +15 -0
  47. package/dist/utils/path.d.ts.map +1 -0
  48. package/dist/utils/path.js +20 -0
  49. package/dist/utils/path.js.map +1 -0
  50. package/dist/utils/workspace.d.ts +13 -0
  51. package/dist/utils/workspace.d.ts.map +1 -0
  52. package/dist/utils/workspace.js +79 -0
  53. package/dist/utils/workspace.js.map +1 -0
  54. package/hegel-vscode/hegel-companion-1.0.10.vsix +0 -0
  55. package/hegel.config.schema.json +0 -5
  56. package/package.json +7 -7
  57. package/hegel-vscode/hegel-companion-1.0.8.vsix +0 -0
package/dist/setup.js CHANGED
@@ -1,89 +1,39 @@
1
1
  #!/usr/bin/env node
2
- import { resolve, join, dirname, parse, posix as posixPath, win32 as win32Path } from "node:path";
3
- import { mkdir, writeFile, readdir, readFile, access, rm } from "node:fs/promises";
4
- import { execFileSync } from "node:child_process";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { loadConfig } from "./config.js";
7
- import { writeHooksFile, configHash } from "./hooks-generator.js";
8
- export const COMPANION_RULE = `# Hegel Companion Rule
9
-
10
- You are working in a project that uses Hegel, a dialectical companion for AI-assisted development.
11
- Hegel monitors your prompts and responses for quality, context drift, and session health.
12
-
13
- ## MCP Tools Available
14
-
15
- You have access to the \`hegel-mcp\` server which provides two tools:
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
- // Subcommand dispatch. `update` is the 1.0.4 single-command refresh path
70
- // for downstream projects (npm i @latest + init --force + prune orphans).
71
- if (positional[0] === "update") {
19
+ const command = positional[0];
20
+ if (command === "update") {
72
21
  return runUpdate(argv, deps);
73
22
  }
74
- if (positional[0] === "init" && positional[1] === "update") {
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 = positional[0];
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
- const projectPath = resolve(targetDir);
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) => {