infernoflow 0.34.0 → 0.34.2

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.
@@ -73,6 +73,7 @@ const COMMAND_DESCRIPTIONS = {
73
73
  stats: "Value dashboard — session memory, tokens injected per session, coverage %, estimated savings",
74
74
  ask: "Query session memory — search gotchas, decisions, and failed attempts by keyword or type",
75
75
  recap: "End-of-session summary — what was captured, what git changes weren't logged, session health score",
76
+ uninstall: "Remove infernoflow from a project — inferno/, CLAUDE.md, MCP server, git hooks (--dry-run to preview)",
76
77
  };
77
78
 
78
79
  const COMMAND_HANDLERS = {
@@ -139,6 +140,7 @@ const COMMAND_HANDLERS = {
139
140
  stats: async (args) => (await import("../lib/commands/stats.mjs")).statsCommand(args),
140
141
  ask: async (args) => (await import("../lib/commands/ask.mjs")).askCommand(args),
141
142
  recap: async (args) => (await import("../lib/commands/recap.mjs")).recapCommand(args),
143
+ uninstall: async (args) => (await import("../lib/commands/uninstall.mjs")).uninstallCommand(args),
142
144
  };
143
145
 
144
146
  function formatCommandsHelp() {
@@ -0,0 +1,406 @@
1
+ /**
2
+ * infernoflow uninstall
3
+ *
4
+ * Removes everything infernoflow installed from a project.
5
+ * The inverse of `infernoflow setup`.
6
+ *
7
+ * What it removes:
8
+ * - inferno/ — contract, capabilities, session memory, HANDOFF.md
9
+ * - CLAUDE.md — auto-behavior instruction file
10
+ * - .claude/settings.json — pre-approved tools (infernoflow entries only)
11
+ * - .cursor/inferno-mcp-server.mjs — MCP server file
12
+ * - .cursor/hooks.json — cursor hooks config (if infernoflow-only)
13
+ * - .cursor/hooks/inferno-session-draft.mjs — cursor hook script
14
+ * - .cursor/mcp.json — infernoflow entry (other entries preserved)
15
+ * - inferno-mcp-server.mjs — root-level MCP server copy
16
+ * - ~/.claude.json — infernoflow mcpServers entry (other entries preserved)
17
+ * - .git/hooks/post-commit / pre-push — infernoflow sections (other hooks preserved)
18
+ *
19
+ * Flags:
20
+ * --dry-run Preview what would be removed without touching anything
21
+ * --keep-memory Preserve inferno/sessions.jsonl (your session logs)
22
+ * --keep-inferno Preserve the entire inferno/ folder
23
+ * --yes / -y Skip confirmation prompt
24
+ * --json Machine-readable output
25
+ *
26
+ * Usage:
27
+ * infernoflow uninstall Interactive — shows plan, asks to confirm
28
+ * infernoflow uninstall --dry-run Show what would be removed
29
+ * infernoflow uninstall --yes Remove without prompting
30
+ * infernoflow uninstall --keep-memory Remove setup but keep session logs
31
+ */
32
+
33
+ import * as fs from "node:fs";
34
+ import * as path from "node:path";
35
+ import * as os from "node:os";
36
+ import * as readline from "node:readline";
37
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
38
+
39
+ const INFERNO_DIR = "inferno";
40
+ const CLAUDE_MD = "CLAUDE.md";
41
+ const CLAUDE_DIR = ".claude";
42
+ const CURSOR_DIR = ".cursor";
43
+ const MCP_SERVER = path.join(CURSOR_DIR, "inferno-mcp-server.mjs");
44
+ const MCP_SERVER_ROOT = "inferno-mcp-server.mjs"; // root-level copy from install-cursor-hooks
45
+ const CURSOR_HOOKS_JSON= path.join(CURSOR_DIR, "hooks.json");
46
+ const CURSOR_HOOK_FILE = path.join(CURSOR_DIR, "hooks", "inferno-session-draft.mjs");
47
+ const CURSOR_MCP = path.join(CURSOR_DIR, "mcp.json");
48
+ const CLAUDE_JSON = path.join(os.homedir(), ".claude.json");
49
+ const GIT_HOOKS = [".git/hooks/post-commit", ".git/hooks/pre-push"];
50
+ const INFERNO_MARKER = "# infernoflow";
51
+
52
+ // ── helpers ──────────────────────────────────────────────────────────────────
53
+
54
+ function exists(p) { return fs.existsSync(p); }
55
+ function readJSON(f) { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } }
56
+
57
+ function readFile(f) {
58
+ try { return fs.readFileSync(f, "utf8"); } catch { return null; }
59
+ }
60
+
61
+ function prompt(question) {
62
+ return new Promise(resolve => {
63
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
64
+ rl.question(question, ans => { rl.close(); resolve(ans); });
65
+ });
66
+ }
67
+
68
+ // ── planners — compute what would be removed ─────────────────────────────────
69
+
70
+ function planInfernoDir(cwd, keepMemory, keepInferno) {
71
+ const items = [];
72
+ const dir = path.join(cwd, INFERNO_DIR);
73
+ if (!exists(dir)) return items;
74
+
75
+ if (keepInferno) {
76
+ items.push({ type: "skip", path: INFERNO_DIR, reason: "--keep-inferno" });
77
+ return items;
78
+ }
79
+
80
+ if (keepMemory) {
81
+ // Remove everything except sessions.jsonl
82
+ const files = fs.readdirSync(dir);
83
+ for (const f of files) {
84
+ if (f === "sessions.jsonl") {
85
+ items.push({ type: "skip", path: path.join(INFERNO_DIR, f), reason: "--keep-memory" });
86
+ } else {
87
+ const full = path.join(dir, f);
88
+ const stat = fs.statSync(full);
89
+ if (stat.isDirectory()) {
90
+ items.push({ type: "rmdir", path: path.join(INFERNO_DIR, f) });
91
+ } else {
92
+ items.push({ type: "rm", path: path.join(INFERNO_DIR, f) });
93
+ }
94
+ }
95
+ }
96
+ } else {
97
+ items.push({ type: "rmdir", path: INFERNO_DIR });
98
+ }
99
+
100
+ return items;
101
+ }
102
+
103
+ function planClaudeMd(cwd) {
104
+ const p = path.join(cwd, CLAUDE_MD);
105
+ if (!exists(p)) return [];
106
+ return [{ type: "rm", path: CLAUDE_MD }];
107
+ }
108
+
109
+ function planClaudeDir(cwd) {
110
+ const items = [];
111
+ const settingsFile = path.join(cwd, CLAUDE_DIR, "settings.json");
112
+ if (!exists(settingsFile)) return items;
113
+
114
+ const settings = readJSON(settingsFile);
115
+ const hasInfernoTools = settings?.tools?.some?.(t => t.startsWith?.("mcp__infernoflow"));
116
+ const hasOtherContent = settings && Object.keys(settings).some(k => {
117
+ if (k === "tools") {
118
+ return (settings.tools || []).some(t => !t.startsWith("mcp__infernoflow"));
119
+ }
120
+ return k !== "tools";
121
+ });
122
+
123
+ if (hasInfernoTools && !hasOtherContent) {
124
+ items.push({ type: "rm", path: path.join(CLAUDE_DIR, "settings.json"), desc: "auto-approved tools" });
125
+ } else if (hasInfernoTools) {
126
+ items.push({ type: "edit", path: path.join(CLAUDE_DIR, "settings.json"), desc: "remove infernoflow tools (preserve other content)" });
127
+ }
128
+ return items;
129
+ }
130
+
131
+ function planCursorMcpServer(cwd) {
132
+ const items = [];
133
+ // .cursor/inferno-mcp-server.mjs
134
+ if (exists(path.join(cwd, MCP_SERVER))) items.push({ type: "rm", path: MCP_SERVER });
135
+ // root-level inferno-mcp-server.mjs (written by install-cursor-hooks)
136
+ if (exists(path.join(cwd, MCP_SERVER_ROOT))) items.push({ type: "rm", path: MCP_SERVER_ROOT });
137
+ // .cursor/hooks/inferno-session-draft.mjs
138
+ if (exists(path.join(cwd, CURSOR_HOOK_FILE))) items.push({ type: "rm", path: CURSOR_HOOK_FILE });
139
+ // .cursor/hooks.json — only remove if it only contains the infernoflow hook
140
+ const hooksJsonPath = path.join(cwd, CURSOR_HOOKS_JSON);
141
+ if (exists(hooksJsonPath)) {
142
+ const cfg = readJSON(hooksJsonPath);
143
+ const hooks = cfg?.hooks || [];
144
+ const hasOnlyInferno = hooks.every(h => (h.name || h.command || "").includes("inferno"));
145
+ if (hasOnlyInferno) {
146
+ items.push({ type: "rm", path: CURSOR_HOOKS_JSON, desc: "infernoflow-only hooks config" });
147
+ } else {
148
+ items.push({ type: "edit", path: CURSOR_HOOKS_JSON, desc: "remove infernoflow hook entry (preserve others)" });
149
+ }
150
+ }
151
+ return items;
152
+ }
153
+
154
+ function planCursorMcpJson(cwd) {
155
+ const p = path.join(cwd, CURSOR_MCP);
156
+ if (!exists(p)) return [];
157
+ const cfg = readJSON(p);
158
+ if (!cfg?.mcpServers?.infernoflow) return [];
159
+ const otherKeys = Object.keys(cfg.mcpServers || {}).filter(k => k !== "infernoflow");
160
+ if (otherKeys.length === 0 && Object.keys(cfg).length === 1) {
161
+ return [{ type: "rm", path: CURSOR_MCP, desc: "infernoflow-only file" }];
162
+ }
163
+ return [{ type: "edit", path: CURSOR_MCP, desc: 'remove "infernoflow" key (preserve other servers)' }];
164
+ }
165
+
166
+ function planClaudeJson() {
167
+ if (!exists(CLAUDE_JSON)) return [];
168
+ const cfg = readJSON(CLAUDE_JSON);
169
+ if (!cfg?.mcpServers?.infernoflow) return [];
170
+ return [{ type: "edit", path: "~/.claude.json", desc: 'remove "infernoflow" MCP entry (preserve other entries)', _realPath: CLAUDE_JSON }];
171
+ }
172
+
173
+ function planGitHooks(cwd) {
174
+ const items = [];
175
+ for (const hookRel of GIT_HOOKS) {
176
+ const hookPath = path.join(cwd, hookRel);
177
+ if (!exists(hookPath)) continue;
178
+ const content = readFile(hookPath);
179
+ if (!content?.includes(INFERNO_MARKER)) continue;
180
+
181
+ const lines = content.split("\n");
182
+ const markerIdx = lines.findIndex(l => l.includes(INFERNO_MARKER));
183
+ const beforeMarker = lines.slice(0, markerIdx).join("\n").trim();
184
+
185
+ if (!beforeMarker || beforeMarker === "#!/bin/sh" || beforeMarker === "#!/bin/bash") {
186
+ items.push({ type: "rm", path: hookRel, desc: "infernoflow-only hook" });
187
+ } else {
188
+ items.push({ type: "edit", path: hookRel, desc: "remove infernoflow section (preserve existing hooks)" });
189
+ }
190
+ }
191
+ return items;
192
+ }
193
+
194
+ // ── executors ─────────────────────────────────────────────────────────────────
195
+
196
+ function removeInfernoDir(cwd, plan, dryRun) {
197
+ for (const item of plan) {
198
+ if (item.type === "skip") continue;
199
+ const full = path.join(cwd, item.path);
200
+ if (dryRun) continue;
201
+ try {
202
+ if (item.type === "rmdir") {
203
+ fs.rmSync(full, { recursive: true, force: true });
204
+ } else {
205
+ fs.unlinkSync(full);
206
+ }
207
+ } catch {}
208
+ }
209
+ }
210
+
211
+ function removeClaudeMd(cwd, dryRun) {
212
+ if (dryRun) return;
213
+ try { fs.unlinkSync(path.join(cwd, CLAUDE_MD)); } catch {}
214
+ }
215
+
216
+ function removeClaudeDir(cwd, plan, dryRun) {
217
+ const settingsPath = path.join(cwd, CLAUDE_DIR, "settings.json");
218
+ for (const item of plan) {
219
+ if (dryRun) continue;
220
+ if (item.type === "rm") {
221
+ try { fs.unlinkSync(path.join(cwd, item.path)); } catch {}
222
+ } else if (item.type === "edit") {
223
+ try {
224
+ const cfg = readJSON(settingsPath);
225
+ if (cfg?.tools) {
226
+ cfg.tools = cfg.tools.filter(t => !t.startsWith("mcp__infernoflow"));
227
+ }
228
+ fs.writeFileSync(settingsPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
229
+ } catch {}
230
+ }
231
+ }
232
+ }
233
+
234
+ function removeCursorMcpServer(cwd, plan, dryRun) {
235
+ if (dryRun) return;
236
+ for (const item of plan) {
237
+ if (item.type === "rm") {
238
+ try { fs.unlinkSync(path.join(cwd, item.path)); } catch {}
239
+ } else if (item.type === "edit" && item.path === CURSOR_HOOKS_JSON) {
240
+ try {
241
+ const cfg = readJSON(path.join(cwd, CURSOR_HOOKS_JSON));
242
+ if (cfg?.hooks) {
243
+ cfg.hooks = cfg.hooks.filter(h => !(h.name || h.command || "").includes("inferno"));
244
+ }
245
+ fs.writeFileSync(path.join(cwd, CURSOR_HOOKS_JSON), JSON.stringify(cfg, null, 2) + "\n", "utf8");
246
+ } catch {}
247
+ }
248
+ }
249
+ }
250
+
251
+ function removeCursorMcpJson(cwd, plan, dryRun) {
252
+ const mcpPath = path.join(cwd, CURSOR_MCP);
253
+ for (const item of plan) {
254
+ if (dryRun) continue;
255
+ if (item.type === "rm") {
256
+ try { fs.unlinkSync(mcpPath); } catch {}
257
+ } else if (item.type === "edit") {
258
+ try {
259
+ const cfg = readJSON(mcpPath);
260
+ if (cfg?.mcpServers?.infernoflow) delete cfg.mcpServers.infernoflow;
261
+ fs.writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
262
+ } catch {}
263
+ }
264
+ }
265
+ }
266
+
267
+ function removeClaudeJson(plan, dryRun) {
268
+ for (const item of plan) {
269
+ if (dryRun) continue;
270
+ const p = item._realPath || CLAUDE_JSON;
271
+ if (item.type === "edit") {
272
+ try {
273
+ const cfg = readJSON(p);
274
+ if (cfg?.mcpServers?.infernoflow) delete cfg.mcpServers.infernoflow;
275
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
276
+ } catch {}
277
+ }
278
+ }
279
+ }
280
+
281
+ function removeGitHooks(cwd, plan, dryRun) {
282
+ for (const item of plan) {
283
+ const hookPath = path.join(cwd, item.path);
284
+ if (dryRun) continue;
285
+ if (item.type === "rm") {
286
+ try { fs.unlinkSync(hookPath); } catch {}
287
+ } else if (item.type === "edit") {
288
+ try {
289
+ const content = readFile(hookPath);
290
+ const lines = content.split("\n");
291
+ const markerIdx = lines.findIndex(l => l.includes(INFERNO_MARKER));
292
+ const preserved = lines.slice(0, markerIdx).join("\n").trimEnd();
293
+ fs.writeFileSync(hookPath, preserved + "\n", "utf8");
294
+ } catch {}
295
+ }
296
+ }
297
+ }
298
+
299
+ // ── entry point ───────────────────────────────────────────────────────────────
300
+
301
+ export async function uninstallCommand(args = []) {
302
+ const has = f => args.includes(f);
303
+ const dryRun = has("--dry-run") || has("--dry");
304
+ const keepMem = has("--keep-memory");
305
+ const keepInf = has("--keep-inferno");
306
+ const skipPrompt= has("--yes") || has("-y");
307
+ const jsonMode = has("--json");
308
+
309
+ const cwd = process.cwd();
310
+
311
+ // ── Build the full removal plan ──────────────────────────────────────────
312
+ const plan = {
313
+ infernoDir: planInfernoDir(cwd, keepMem, keepInf),
314
+ claudeMd: planClaudeMd(cwd),
315
+ claudeDir: planClaudeDir(cwd),
316
+ cursorMcpServer: planCursorMcpServer(cwd),
317
+ cursorMcpJson: planCursorMcpJson(cwd),
318
+ claudeJson: planClaudeJson(),
319
+ gitHooks: planGitHooks(cwd),
320
+ };
321
+
322
+ const allItems = Object.values(plan).flat();
323
+ const actionItems = allItems.filter(i => i.type !== "skip");
324
+
325
+ if (jsonMode) {
326
+ console.log(JSON.stringify({ dryRun, keepMemory: keepMem, keepInferno: keepInf, plan, actionCount: actionItems.length }, null, 2));
327
+ return;
328
+ }
329
+
330
+ const SEP = gray(" " + "─".repeat(52));
331
+
332
+ console.log();
333
+ console.log(" " + bold("🔥 infernoflow uninstall"));
334
+ if (dryRun) console.log(yellow(" DRY RUN — nothing will be changed"));
335
+ console.log(SEP);
336
+
337
+ if (actionItems.length === 0) {
338
+ console.log();
339
+ console.log(green(" ✔ Nothing to remove — infernoflow is not installed in this project"));
340
+ console.log();
341
+ return;
342
+ }
343
+
344
+ // ── Print the plan ───────────────────────────────────────────────────────
345
+ console.log();
346
+ console.log(" " + bold("Will remove:"));
347
+ console.log();
348
+
349
+ const typeIcon = { rm: red(" ✖"), rmdir: red(" ✖"), edit: yellow(" ~"), skip: gray(" ·") };
350
+
351
+ for (const item of allItems) {
352
+ const icon = typeIcon[item.type] || " ?";
353
+ const label = item.desc ? gray(` (${item.desc})`) : "";
354
+ console.log(`${icon} ${item.path}${label}`);
355
+ }
356
+
357
+ if (keepMem) {
358
+ console.log();
359
+ console.log(gray(" ℹ inferno/sessions.jsonl will be preserved (--keep-memory)"));
360
+ }
361
+
362
+ console.log();
363
+
364
+ // ── Confirm ──────────────────────────────────────────────────────────────
365
+ if (!dryRun && !skipPrompt) {
366
+ const ans = await prompt(" Continue? " + gray("[y/N] ") );
367
+ if (!ans.trim().toLowerCase().startsWith("y")) {
368
+ console.log(gray("\n Aborted — nothing changed.\n"));
369
+ return;
370
+ }
371
+ console.log();
372
+ }
373
+
374
+ if (dryRun) {
375
+ console.log(gray(" ↑ Dry run complete — run without --dry-run to apply\n"));
376
+ return;
377
+ }
378
+
379
+ // ── Execute ──────────────────────────────────────────────────────────────
380
+ removeInfernoDir(cwd, plan.infernoDir, false);
381
+ if (plan.claudeMd.length) removeClaudeMd(cwd, false);
382
+ if (plan.claudeDir.length) removeClaudeDir(cwd, plan.claudeDir, false);
383
+ if (plan.cursorMcpServer.length) removeCursorMcpServer(cwd, plan.cursorMcpServer, false);
384
+ if (plan.cursorMcpJson.length) removeCursorMcpJson(cwd, plan.cursorMcpJson, false);
385
+ if (plan.claudeJson.length) removeClaudeJson(plan.claudeJson, false);
386
+ if (plan.gitHooks.length) removeGitHooks(cwd, plan.gitHooks, false);
387
+
388
+ // ── Summary ──────────────────────────────────────────────────────────────
389
+ console.log(SEP);
390
+ console.log();
391
+ console.log(green(" ✔ infernoflow removed from this project"));
392
+ console.log();
393
+
394
+ const edited = actionItems.filter(i => i.type === "edit");
395
+ const removed = actionItems.filter(i => i.type === "rm" || i.type === "rmdir");
396
+
397
+ if (removed.length) console.log(gray(` Deleted: `) + removed.map(i => i.path).join(", "));
398
+ if (edited.length) console.log(gray(` Edited: `) + edited.map(i => i.path).join(", "));
399
+
400
+ if (keepMem) {
401
+ console.log();
402
+ console.log(gray(" Session memory kept → inferno/sessions.jsonl"));
403
+ console.log(gray(" Re-run infernoflow init to restore the rest."));
404
+ }
405
+ console.log();
406
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {