infernoflow 0.44.19 → 0.44.20
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 +6 -5
- package/dist/lib/commands/setup.mjs +43 -17
- package/dist/lib/cursorHooksInstall.mjs +1 -1
- package/dist/lib/securityRefresh.mjs +2 -0
- package/dist/lib/upgradeCheck.mjs +4 -2
- package/dist/templates/cursor/hooks/inferno-session-draft.mjs +41 -9
- package/dist/templates/cursor/inferno-mcp-server.mjs +165 -63
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -267,13 +267,14 @@ That's the whole product. No vendor lock-in (it's JSONL on disk). No SaaS. One C
|
|
|
267
267
|
|
|
268
268
|
Local-first by design:
|
|
269
269
|
|
|
270
|
-
-
|
|
270
|
+
- ✅ **Telemetry is opt-in and off by default.** After a few runs in an interactive terminal infernoflow asks once; nothing is sent unless you answer yes. It never sends code, file paths or memory content. Check or change it with `infernoflow telemetry status`.
|
|
271
271
|
- 🚫 **No `postinstall` script.** `npm install -g infernoflow` runs no code — it only copies files.
|
|
272
|
-
- 🚫 **No network calls in any default command path
|
|
273
|
-
- 🚫 **No auto-updates, no background processes, no cloud sync.**
|
|
274
|
-
-
|
|
272
|
+
- 🚫 **No network calls in any default command path** (with telemetry off, which is the default).
|
|
273
|
+
- 🚫 **No auto-updates of the package, no background processes, no cloud sync.**
|
|
274
|
+
- ⚠️ **Writes outside the project:** `setup` (and the automatic refresh after an upgrade) registers the MCP server in your AI tools' config files — `~/.claude.json` and the Claude Desktop config. Everything else stays in the project (`.ai-memory/`, `.cursor/`, `.claude/`, `.vscode/`, the three rule files). Moving these registrations into per-project config is in progress.
|
|
275
275
|
- ✅ **Auto-injected content is wrapped in markers** (`<!-- infernoflow:start -->` / `<!-- infernoflow:end -->`) — your manual edits outside the block are never touched.
|
|
276
|
-
-
|
|
276
|
+
- ⚠️ **Memory files are plain text and are committed with your repo.** Don't paste secrets into prompts or log messages — automatic secret redaction is not implemented yet.
|
|
277
|
+
- ✅ **No shell is used to run commands** from the MCP server or the prompt hooks (since 0.44.20), and MCP tool arguments are validated against their schema.
|
|
277
278
|
|
|
278
279
|
The optional `infernoflow ai setup` command wires an AI provider (Anthropic / OpenAI / Google / Ollama) for a few enrichment commands — same trust model as using that provider directly. Off by default.
|
|
279
280
|
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import*as a from"node:fs";import*as u from"node:path";import*as
|
|
1
|
+
import*as a from"node:fs";import*as u from"node:path";import*as w from"node:os";import{fileURLToPath as N}from"node:url";import{execSync as I}from"node:child_process";import{detectIdeContext as M}from"../ai/ideDetection.mjs";import{header as J,ok as k,warn as E,info as v,done as D,cyan as p,yellow as C,bold as h,green as g,gray as j}from"../ui/output.mjs";import"../cursorHooksInstall.mjs";import{refreshSecuritySensitiveCopies as L}from"../securityRefresh.mjs";import"../vsCodeCopilotHooksInstall.mjs";import{updateInjectionConfig as T}from"../amp/io.mjs";import{findProjectRoot as A}from"../projectRoot.mjs";import{injectionPatchFromArgs as H}from"./refresh.mjs";const W=u.dirname(N(import.meta.url));function _(){return u.resolve(W,"../../templates")}function U(t){try{return I(`npx infernoflow ${t}`,{encoding:"utf8",cwd:process.cwd(),timeout:6e4,stdio:["inherit","pipe","pipe"]})}catch(i){return i.stdout||i.stderr||i.message}}function y(t){if(!a.existsSync(t))return{data:{},existed:!1,corrupt:!1,backup:null};let i;try{i=a.readFileSync(t,"utf8")}catch{return{data:{},existed:!0,corrupt:!1,backup:null}}try{const s=JSON.parse(i);return!s||typeof s!="object"||Array.isArray(s)?{data:{},existed:!0,corrupt:!1,backup:null}:{data:s,existed:!0,corrupt:!1,backup:null}}catch{let s=null;try{s=`${t}.corrupt-${Date.now()}.bak`,a.writeFileSync(s,i,"utf8")}catch{s=null}return{data:{},existed:!0,corrupt:!0,backup:s}}}function V(){const t=w.homedir();return process.platform==="win32"?u.join(t,"AppData","Roaming","Claude","claude_desktop_config.json"):process.platform==="darwin"?u.join(t,"Library","Application Support","Claude","claude_desktop_config.json"):u.join(t,".config","Claude","claude_desktop_config.json")}function K(t,i){const s=V(),n=u.dirname(s);if(!a.existsSync(n)&&!a.existsSync(s))return{updated:!1,skipped:"not-installed"};const{data:r,backup:o}=y(s);r.mcpServers||(r.mcpServers={});const l=r.mcpServers.infernoflow;if(l&&Array.isArray(l.args)&&l.args[0]===t&&l.env&&l.env.INFERNOFLOW_PROJECT_DIR===i)return{updated:!1,backup:o};const c={...l&&l.env?l.env:{}};i&&(c.INFERNOFLOW_PROJECT_DIR=i),r.mcpServers.infernoflow={command:"node",args:[t],env:c};const d=JSON.stringify(r,null,2);return a.mkdirSync(n,{recursive:!0}),a.writeFileSync(s,d,"utf8"),{updated:!0,path:s,backup:o}}function q(t){const i=u.join(t,".git");if(!a.existsSync(i))return{installed:!1,skipped:"not-a-git-repo"};const s=u.join(i,"hooks"),n=u.join(s,"post-commit"),r='infernoflow log "commit: $(git log -1 --pretty=%s)" --type note --source git-hook --auto --quiet >/dev/null 2>&1 || true';try{if(a.mkdirSync(s,{recursive:!0}),a.existsSync(n)){const o=a.readFileSync(n,"utf8");if(o.includes("infernoflow"))return{installed:!1,already:!0};const l=o.replace(/\s*$/,"")+`
|
|
2
2
|
|
|
3
3
|
# infernoflow auto-capture
|
|
4
4
|
`+r+`
|
|
5
|
-
`;a.writeFileSync(n,l,"utf8")}else{const
|
|
6
|
-
`);a.writeFileSync(n,
|
|
5
|
+
`;a.writeFileSync(n,l,"utf8")}else{const o=["#!/bin/sh","# infernoflow: auto-capture the commit subject into session memory.","# Best-effort and non-blocking \u2014 never fails a commit.",r,""].join(`
|
|
6
|
+
`);a.writeFileSync(n,o,"utf8")}try{a.chmodSync(n,493)}catch{}return{installed:!0,path:n}}catch(o){return{installed:!1,error:o.message}}}const B=2,b=`#!/usr/bin/env node
|
|
7
7
|
// infernoflow UserPromptSubmit hook (Claude Code / Cursor).
|
|
8
|
+
// infernoflow-hook-version: ${B}
|
|
8
9
|
// Logs a best-effort 'attempt' entry when the user's prompt shows frustration,
|
|
9
10
|
// so the highest-value capture signal doesn't depend on the model remembering.
|
|
10
|
-
// Never blocks the prompt.
|
|
11
|
-
import { readFileSync } from "node:fs";
|
|
12
|
-
import { spawnSync } from "node:child_process";
|
|
11
|
+
// Never blocks the prompt. Never uses a shell.
|
|
12
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
13
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
13
15
|
|
|
14
16
|
let raw = "";
|
|
15
17
|
try { raw = readFileSync(0, "utf8"); } catch {}
|
|
@@ -17,19 +19,43 @@ let prompt = "";
|
|
|
17
19
|
try { const j = JSON.parse(raw); prompt = j.prompt || j.user_prompt || j.userPrompt || ""; }
|
|
18
20
|
catch { prompt = raw; }
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
// Find the CLI's JavaScript entry point (never the .cmd / shell wrapper).
|
|
23
|
+
function findCliMjs() {
|
|
24
|
+
let hits = [];
|
|
23
25
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
27
|
+
hits = execFileSync(finder, ["infernoflow"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, timeout: 3000 })
|
|
28
|
+
.split(/\\r?\\n/).map((s) => s.trim()).filter(Boolean);
|
|
27
29
|
} catch {}
|
|
30
|
+
for (const c of hits) {
|
|
31
|
+
try { const real = realpathSync(c); if (/\\.m?js$/i.test(real)) return real; } catch {}
|
|
32
|
+
const d = dirname(c);
|
|
33
|
+
for (const pkg of [join(d, "node_modules", "infernoflow"), join(d, "..", "lib", "node_modules", "infernoflow")]) {
|
|
34
|
+
for (const f of [join(pkg, "dist", "bin", "infernoflow.mjs"), join(pkg, "bin", "infernoflow.mjs")]) {
|
|
35
|
+
if (existsSync(f)) return f;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MARKERS = [/!!+/, /not working/i, /still (broken|failing|not)/i, /does ?n'?t work/i, /\\bbroken\\b/i, /\\bretry(ing)?\\b/i, /same error/i, /no change/i];
|
|
43
|
+
if (prompt && MARKERS.some((re) => re.test(prompt))) {
|
|
44
|
+
// Leading dashes are stripped so the text can never be read as a CLI flag.
|
|
45
|
+
const msg = "User frustration: " + prompt.replace(/\\s+/g, " ").trim().replace(/^[\\s-]+/, "").slice(0, 120);
|
|
46
|
+
const cli = findCliMjs();
|
|
47
|
+
if (cli) {
|
|
48
|
+
try {
|
|
49
|
+
spawnSync(process.execPath, [cli, "log", msg, "--type", "attempt", "--result", "failed", "--auto", "--quiet", "--source", "hook"], {
|
|
50
|
+
stdio: "ignore", timeout: 5000, windowsHide: true, shell: false,
|
|
51
|
+
});
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
28
54
|
}
|
|
29
55
|
process.exit(0);
|
|
30
|
-
`;function
|
|
31
|
-
`,"utf8"),{updated:!0,path:r,backup:l}}function
|
|
32
|
-
`,"utf8"),
|
|
56
|
+
`;function G(t){const i=u.join(t,".claude","hooks"),s=u.join(i,"log-frustration.mjs");try{a.mkdirSync(i,{recursive:!0}),a.writeFileSync(s,b,"utf8");try{a.chmodSync(s,493)}catch{}}catch(c){return{installed:!1,error:c.message}}const n=u.join(t,".claude","settings.json"),{data:r}=y(n);(!r.hooks||typeof r.hooks!="object")&&(r.hooks={}),Array.isArray(r.hooks.UserPromptSubmit)||(r.hooks.UserPromptSubmit=[]);const o="node .claude/hooks/log-frustration.mjs",l=r.hooks.UserPromptSubmit.some(c=>Array.isArray(c?.hooks)&&c.hooks.some(d=>typeof d?.command=="string"&&d.command.includes("log-frustration.mjs")));l||r.hooks.UserPromptSubmit.push({hooks:[{type:"command",command:o}]});try{a.mkdirSync(u.dirname(n),{recursive:!0}),a.writeFileSync(n,JSON.stringify(r,null,2),"utf8")}catch(c){return{installed:!0,registered:!1,error:c.message}}return{installed:!0,registered:!l}}const z=["infernoflow_status","infernoflow_run","infernoflow_apply","infernoflow_check","infernoflow_context","infernoflow_implement","infernoflow_git_drift","infernoflow_scan_ui","infernoflow_review","amp_read","amp_write","amp_search","amp_handoff","amp_health"];function Q(t,i){const s=u.join(w.homedir(),".claude.json"),{data:n,backup:r}=y(s);n.mcpServers||(n.mcpServers={});const o=n.mcpServers.infernoflow;if(o&&o.args&&o.args[0]===t&&o.env&&o.env.INFERNOFLOW_PROJECT_DIR===i)return{updated:!1,backup:r};const l={...o&&o.env?o.env:{}};i&&(l.INFERNOFLOW_PROJECT_DIR=i),n.mcpServers.infernoflow={command:"node",args:[t],env:l};const c=JSON.stringify(n,null,2).replace(/\u0000+/g,"");return a.writeFileSync(s,c,"utf8"),{updated:!0,path:s,backup:r}}function X(t,i,s){const n=u.join(t,".vscode"),r=u.join(n,"mcp.json"),{data:o,backup:l}=y(r);o.servers||(o.servers={});const c=o.servers.infernoflow;if(c&&c.args&&c.args[0]===i&&c.env&&c.env.INFERNOFLOW_PROJECT_DIR===s)return{updated:!1,backup:l};const d={...c&&c.env?c.env:{}};return s&&(d.INFERNOFLOW_PROJECT_DIR=s),o.servers.infernoflow={type:"stdio",command:"node",args:[i],env:d},a.mkdirSync(n,{recursive:!0}),a.writeFileSync(r,JSON.stringify(o,null,2)+`
|
|
57
|
+
`,"utf8"),{updated:!0,path:r,backup:l}}function Y(t,i){const s=u.join(t,".claude"),n=u.join(s,"settings.json"),{data:r}=y(n),o=new Set(r.allowedTools||[]);for(const c of z)o.add(`mcp__infernoflow__${c}`);const l={...r,allowedTools:[...o]};return a.mkdirSync(s,{recursive:!0}),a.writeFileSync(n,JSON.stringify(l,null,2),"utf8"),n}function Z(t,{silent:i=!1}={}){const s=_(),n=i?()=>{}:e=>k(e),r=i?()=>{}:e=>E(e),o={mcpServer:!1,claudeJson:!1,claudeSettings:!1,claudeDesktop:!1,gitHooks:!1,captureHook:!1,backups:[]},l=u.join(s,"cursor","inferno-mcp-server.mjs"),c=u.join(t,".cursor","inferno-mcp-server.mjs"),d=(()=>{try{return A(t)}catch{return t}})();try{a.existsSync(c)||(a.mkdirSync(u.dirname(c),{recursive:!0}),a.copyFileSync(l,c),o.mcpServer=!0,n("Copied MCP server \u2192 "+p(".cursor/inferno-mcp-server.mjs")))}catch(e){r("MCP server copy skipped: "+e.message)}try{const e=L(t,{captureHookScript:b});e.length&&(o.securityRefreshed=e,e.some(f=>f.endsWith("inferno-mcp-server.mjs"))&&(o.mcpServer=!0),n("Security update: replaced outdated "+e.map(f=>p(f)).join(", ")))}catch{}try{const e=Q(c,d);e.updated&&(o.claudeJson=!0,n("Registered MCP server in "+p("~/.claude.json"))),e.backup&&(o.backups.push(e.backup),r("~/.claude.json was unreadable \u2014 backed up to "+e.backup+" before rewrite"))}catch(e){r("~/.claude.json update skipped: "+e.message)}try{X(t,c,d).updated&&(o.vscodeMcp=!0,n("Registered MCP server in "+p(".vscode/mcp.json")+j(" (Copilot Chat)")))}catch(e){r(".vscode/mcp.json update skipped: "+e.message)}try{const e=u.join(t,".cursor","mcp.json"),{data:f}=y(e);f.mcpServers||(f.mcpServers={});const m=f.mcpServers.infernoflow;(!m||!m.args||m.args[0]!==c||!m.env||m.env.INFERNOFLOW_PROJECT_DIR!==d)&&(f.mcpServers.infernoflow={command:"node",args:[c],env:{...m&&m.env?m.env:{},INFERNOFLOW_PROJECT_DIR:d}},a.mkdirSync(u.dirname(e),{recursive:!0}),a.writeFileSync(e,JSON.stringify(f,null,2)+`
|
|
58
|
+
`,"utf8"),o.cursorMcp=!0,n("Registered MCP server in "+p(".cursor/mcp.json")))}catch(e){r(".cursor/mcp.json update skipped: "+e.message)}try{Y(t,!1),o.claudeSettings=!0,n("Pre-approved infernoflow tools in "+p(".claude/settings.json"))}catch(e){r(".claude/settings.json skipped: "+e.message)}try{const e=K(c,d);e.updated&&(o.claudeDesktop=!0,n("Registered MCP server in "+p("claude_desktop_config.json")+j(" (Claude Desktop app)"))),e.backup&&(o.backups.push(e.backup),r("Claude Desktop config was unreadable \u2014 backed up to "+e.backup))}catch(e){r("Claude Desktop config skipped: "+e.message)}try{q(t).installed&&(o.gitHooks=!0,n("Installed git post-commit hook \u2192 "+p(".git/hooks/post-commit")))}catch(e){r("git hook install skipped: "+e.message)}try{G(t).installed&&(o.captureHook=!0,n("Installed capture hook \u2192 "+p(".claude/hooks/log-frustration.mjs")))}catch(e){r("capture hook install skipped: "+e.message)}return o}async function de(t){const i=process.cwd(),s=t.includes("--force")||t.includes("-f"),n=t.includes("--yes")||t.includes("-y"),r=_();J("infernoflow setup");const{ideDetected:o}=M("auto");v(`IDE detected: ${h(o==="cursor"?"Cursor":o==="vscode"?"VS Code":o==="windsurf"?"Windsurf":"unknown")}`);const c=u.join(i,".ai-memory");a.existsSync(c)?k(".ai-memory/ already exists \u2014 skipping init"):(console.log(`
|
|
33
59
|
${C(".ai-memory/")} not found \u2014 running init ...
|
|
34
|
-
`),
|
|
35
|
-
${
|
|
60
|
+
`),U(n?"init --yes":"init"));const d=H(t);if(Object.keys(d).length)try{T(i,d),k("Injection config updated \u2192 "+JSON.stringify(d))}catch{}console.log(),v("Wiring up MCP servers for Cursor / VS Code Copilot / Claude Code ...");const e=Z(i,{silent:!1});console.log(),D("infernoflow ready"),console.log(`
|
|
61
|
+
${h("What was set up:")}`),console.log(` ${g("\u2714")} MCP server installed \u2192 ${p(".cursor/inferno-mcp-server.mjs")}`),e.cursorMcp&&console.log(` ${g("\u2714")} Cursor MCP config \u2192 ${p(".cursor/mcp.json")}`),e.vscodeMcp&&console.log(` ${g("\u2714")} VS Code Copilot MCP config \u2192 ${p(".vscode/mcp.json")}`),e.claudeJson&&console.log(` ${g("\u2714")} Claude Code MCP config \u2192 ${p("~/.claude.json")}`),e.claudeSettings&&console.log(` ${g("\u2714")} Auto-approved tools \u2192 ${p(".claude/settings.json")}`),e.claudeDesktop&&console.log(` ${g("\u2714")} Claude Desktop MCP config \u2192 ${p("claude_desktop_config.json")}`),e.gitHooks&&console.log(` ${g("\u2714")} Git post-commit hook \u2192 ${p(".git/hooks/post-commit")}`),e.captureHook&&console.log(` ${g("\u2714")} Capture hook (Claude Code/Cursor) \u2192 ${p(".claude/hooks/log-frustration.mjs")}`);try{const{detectStaleMcpRuntime:f}=await import("../mcpRuntime.mjs"),{readFileSync:m}=await import("node:fs"),{dirname:R,join:x}=await import("node:path"),{fileURLToPath:O}=await import("node:url"),P=R(O(import.meta.url)),F=x(P,"..","..","package.json"),$=JSON.parse(m(F,"utf8")).version,S=f(i,$);S&&(console.log(),console.log(` ${C("\u26A0")} ${h("Restart required:")} ${S.message}`))}catch{}console.log(),console.log(` ${h("Next step:")} Restart your AI tool. Test by asking:`),console.log(` ${p('"call the amp_write tool with a test note"')}`),console.log()}export{b as CAPTURE_HOOK_SCRIPT,B as CAPTURE_HOOK_VERSION,z as MCP_TOOLS,Z as autoSetupMcp,V as claudeDesktopConfigPath,G as installClaudeCodeCaptureHook,q as installGitHooks,de as setupCommand,K as updateClaudeDesktopConfig,Q as updateClaudeJson,X as updateVscodeMcpJson,Y as writeClaudeSettings};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import*as
|
|
1
|
+
import*as e from"node:fs";import*as r from"node:path";import{installInfernoDraftTooling as v}from"./draftToolingInstall.mjs";import{refreshSecuritySensitiveCopies as h}from"./securityRefresh.mjs";function H(m){const{cwd:s,templatesRoot:t,force:a,silent:n}=m,f=m.logOk||(()=>{}),p=m.logWarn||(()=>{});function l(o,c){return e.existsSync(c)&&!a?(n||p("Skipped (exists): "+r.relative(s,c)),!1):(e.mkdirSync(r.dirname(c),{recursive:!0}),e.copyFileSync(o,c),n||f("Created: "+r.relative(s,c)),!0)}v({cwd:s,templatesRoot:t,force:a,silent:n,logOk:f,logWarn:p});const u=r.join(t,"cursor","hooks.json"),j=r.join(s,".cursor","hooks.json"),S=r.join(t,"cursor","hooks","inferno-session-draft.mjs"),d=r.join(s,".cursor","hooks","inferno-session-draft.mjs");l(u,j),l(S,d);const k=r.join(t,"cursor","inferno-mcp-server.mjs"),y=r.join(s,".cursor","inferno-mcp-server.mjs");l(k,y);try{const o=h(s);o.length&&!n&&f("Security update: replaced "+o.join(", "))}catch{}const i=r.join(s,".cursor","mcp.json");if(!e.existsSync(i)||a){let o={};if(e.existsSync(i))try{o=JSON.parse(e.readFileSync(i,"utf8"))}catch{}o.mcpServers||(o.mcpServers={}),o.mcpServers.infernoflow={command:"node",args:["./inferno-mcp-server.mjs"],env:{}},e.mkdirSync(r.dirname(i),{recursive:!0}),e.writeFileSync(i,JSON.stringify(o,null,2),"utf8"),n||f("Created: .cursor/mcp.json")}else n||p("Skipped (exists): .cursor/mcp.json")}export{H as installCursorHooksArtifacts};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import*as c from"node:fs";import*as n from"node:path";import{fileURLToPath as d}from"node:url";const h=n.dirname(d(import.meta.url));function k(){return n.resolve(h,"../templates")}const p="infernoflow-hook-version: 2";function t(r){try{return c.readFileSync(r,"utf8")}catch{return null}}const j=r=>r==null?r:r.replace(/\r\n/g,`
|
|
2
|
+
`);function v(r){return typeof r=="string"&&(r.includes('serverInfo: { name: "infernoflow"')||r.includes("[infernoflow MCP]"))}function y(r,u={}){const f=[],l=k(),i=(o,e)=>{const a=n.join(r,o);try{c.mkdirSync(n.dirname(a),{recursive:!0}),c.writeFileSync(a,e,"utf8"),f.push(o.split(n.sep).join("/"))}catch{}},s=t(n.join(l,"cursor","inferno-mcp-server.mjs"));if(s)for(const o of[n.join(".cursor","inferno-mcp-server.mjs"),"inferno-mcp-server.mjs"]){const e=t(n.join(r,o));e!=null&&v(e)&&j(e)!==j(s)&&i(o,s)}const m=t(n.join(l,"cursor","hooks","inferno-session-draft.mjs"));if(m){const o=n.join(".cursor","hooks","inferno-session-draft.mjs"),e=t(n.join(r,o));e!=null&&!e.includes(p)&&i(o,m)}if(u.captureHookScript){const o=n.join(".claude","hooks","log-frustration.mjs"),e=t(n.join(r,o));e!=null&&!e.includes(p)&&i(o,u.captureHookScript)}return f}export{p as HOOK_VERSION_MARKER,y as refreshSecuritySensitiveCopies};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import*as
|
|
2
|
-
|
|
1
|
+
import*as n from"node:fs";import*as a from"node:path";const u=new Set(["--help","-h","--version","-v","commands","doctor","setup","init","uninstall"]);function p(e){const o=a.join(e,".ai-memory"),t=a.join(e,"inferno");return n.existsSync(o)?a.join(o,".last-cli-version"):n.existsSync(t)?a.join(t,".last-cli-version"):null}function y(e){try{return n.readFileSync(e,"utf8").trim()}catch{return""}}async function m(e,o){if(!e||o&&u.has(o))return;const t=process.cwd(),c=p(t);if(!c)return;const s=y(c);if(s===e)return;let f=!1,l=[],d=s==="";try{const{refreshRuleFilesFromMemory:i}=await import("./ruleFiles.mjs");i(t)}catch{}try{const{applyCleanTreePolicy:i}=await import("./cleanTree.mjs");i(t)}catch{}try{const{autoSetupMcp:i}=await import("./commands/setup.mjs"),r=i(t,{silent:!0});f=!!(r&&(r.mcpServer||r.claudeJson||r.cursorMcp||r.vscodeMcp||r.claudeSettings)),l=r&&r.securityRefreshed||[]}catch{}try{n.writeFileSync(c,e,"utf8")}catch{}if(l.length&&process.stderr.write(` infernoflow: security update \u2014 replaced outdated ${l.join(", ")}.
|
|
2
|
+
infernoflow: restart your AI tool (Claude Code / Cursor / VS Code) so the new MCP server is loaded.
|
|
3
|
+
`),f||d)try{const{gray:i}=await import("./ui/output.mjs"),r=s?`upgraded ${s} \u2192 ${e}`:`initialized for ${e}`;process.stderr.write(i(` infernoflow: ${r}, wired MCP servers + rule files
|
|
4
|
+
`))}catch{process.stderr.write(` infernoflow: refreshed for v${e}
|
|
3
5
|
`)}}export{m as runUpgradeBackfillIfNeeded};
|
|
@@ -16,7 +16,41 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
-
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { spawnSync, execFileSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
// infernoflow-hook-version: 2
|
|
22
|
+
// SECURITY (0.44.20): the CLI is run as `node infernoflow.mjs ...` with NO
|
|
23
|
+
// shell. Before 0.44.20 this hook used spawnSync("infernoflow.cmd", args,
|
|
24
|
+
// { shell: true }) on Windows, which hands the prompt text to cmd.exe
|
|
25
|
+
// unquoted — a prompt containing `&` or `|` ran as a command.
|
|
26
|
+
function findCliMjs() {
|
|
27
|
+
let hits = [];
|
|
28
|
+
try {
|
|
29
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
30
|
+
hits = execFileSync(finder, ["infernoflow"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, timeout: 3000 })
|
|
31
|
+
.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
32
|
+
} catch {}
|
|
33
|
+
for (const c of hits) {
|
|
34
|
+
try { const real = fs.realpathSync(c); if (/\.m?js$/i.test(real)) return real; } catch {}
|
|
35
|
+
const d = path.dirname(c);
|
|
36
|
+
for (const pkg of [path.join(d, "node_modules", "infernoflow"), path.join(d, "..", "lib", "node_modules", "infernoflow")]) {
|
|
37
|
+
for (const f of [path.join(pkg, "dist", "bin", "infernoflow.mjs"), path.join(pkg, "bin", "infernoflow.mjs")]) {
|
|
38
|
+
if (fs.existsSync(f)) return f;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run the infernoflow CLI without a shell. Returns the spawnSync result or null. */
|
|
46
|
+
function runCli(args, opts) {
|
|
47
|
+
const cli = findCliMjs();
|
|
48
|
+
if (!cli) return null;
|
|
49
|
+
return spawnSync(process.execPath, [cli, ...args], { ...opts, windowsHide: true, shell: false });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Text that becomes a CLI argument must never look like a flag. */
|
|
53
|
+
const asCliText = (v) => String(v ?? "").replace(/^[\s-]+/, "");
|
|
20
54
|
|
|
21
55
|
/** Keep in sync with templates/scripts/inferno-promote-draft.mjs */
|
|
22
56
|
const DRAFT_HEADER = `# CONTEXT draft (gitignored)
|
|
@@ -201,11 +235,10 @@ function handleBookmarkTrigger(prompt) {
|
|
|
201
235
|
// The `bookmark` command (no --marker) auto-captures the session transcript
|
|
202
236
|
// as the resume point. Needs infernoflow >= 0.44.10 on PATH; if the global
|
|
203
237
|
// CLI is older / missing, this no-ops and the AI's amp_bookmark path covers it.
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
cwd: projectRoot(), encoding: "utf8", timeout: 12000, shell: process.platform === "win32",
|
|
238
|
+
const r = runCli(["bookmark", asCliText(label)], {
|
|
239
|
+
cwd: projectRoot(), encoding: "utf8", timeout: 12000,
|
|
207
240
|
});
|
|
208
|
-
wrote = r.status === 0;
|
|
241
|
+
wrote = !!r && r.status === 0;
|
|
209
242
|
} catch { /* CLI unavailable — skip */ }
|
|
210
243
|
|
|
211
244
|
if (wrote) {
|
|
@@ -240,11 +273,10 @@ function handleUserPrompt(text) {
|
|
|
240
273
|
// direct sessions.jsonl append so capture still works without a global CLI.
|
|
241
274
|
let wrote = false;
|
|
242
275
|
try {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
cwd: projectRoot(), encoding: "utf8", timeout: 8000, shell: process.platform === "win32",
|
|
276
|
+
const r = runCli(["log", asCliText(msg), "--type", "attempt", "--source", "cursor-trigger", "--tags", "auto-trigger"], {
|
|
277
|
+
cwd: projectRoot(), encoding: "utf8", timeout: 8000,
|
|
246
278
|
});
|
|
247
|
-
wrote = r.status === 0;
|
|
279
|
+
wrote = !!r && r.status === 0;
|
|
248
280
|
} catch { /* fall through to direct write */ }
|
|
249
281
|
if (!wrote) {
|
|
250
282
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline";
|
|
@@ -37,6 +37,19 @@ function walkUpForInfernoflow(startFile) {
|
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Locate the global `infernoflow` launcher(s) on PATH WITHOUT a shell.
|
|
42
|
+
* `where` (Windows) / `which` (POSIX) are real executables, so execFileSync
|
|
43
|
+
* with an argument array is enough. Returns [] when nothing is found.
|
|
44
|
+
*/
|
|
45
|
+
function lookupOnPath(name) {
|
|
46
|
+
try {
|
|
47
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
48
|
+
const out = execFileSync(finder, [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false });
|
|
49
|
+
return out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
50
|
+
} catch { return []; }
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
function findInfernoflowRoot() {
|
|
41
54
|
// 1. Walk up from this template's own location.
|
|
42
55
|
// Works when the template runs from inside infernoflow-pkg/ or from a
|
|
@@ -56,9 +69,7 @@ function findInfernoflowRoot() {
|
|
|
56
69
|
// and the user's project doesn't depend on infernoflow locally. Without
|
|
57
70
|
// this branch the MCP server boots with v0.0.0-unknown.
|
|
58
71
|
try {
|
|
59
|
-
const
|
|
60
|
-
const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
61
|
-
for (const candidate of out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
|
|
72
|
+
for (const candidate of lookupOnPath("infernoflow")) {
|
|
62
73
|
if (!fs.existsSync(candidate)) continue;
|
|
63
74
|
const binDir = path.dirname(candidate);
|
|
64
75
|
// Windows layout: <npm-prefix>/infernoflow.cmd + <npm-prefix>/node_modules/infernoflow/
|
|
@@ -89,20 +100,40 @@ function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code
|
|
|
89
100
|
// Resolve a deterministic location once at startup, in priority order:
|
|
90
101
|
// 1. infernoflow installed in the project's node_modules (npm i / npm link)
|
|
91
102
|
// 2. `where`/`which` the global binary
|
|
92
|
-
// Returns null if nothing is found;
|
|
103
|
+
// Returns null if nothing is found; runCli surfaces a clear error in that case.
|
|
104
|
+
// SECURITY: this must always resolve to the CLI's JavaScript entry point
|
|
105
|
+
// (infernoflow.mjs), which we run as `node <file> ...args` with NO shell.
|
|
106
|
+
// We never execute the npm `.cmd` / shell-script wrapper: running those needs
|
|
107
|
+
// a shell, and a shell turns tool arguments into commands.
|
|
93
108
|
function resolveInfernoflowBin() {
|
|
94
|
-
|
|
109
|
+
const fromRoot = (root) => {
|
|
95
110
|
for (const c of [
|
|
96
|
-
path.join(
|
|
97
|
-
path.join(
|
|
111
|
+
path.join(root, "dist", "bin", "infernoflow.mjs"),
|
|
112
|
+
path.join(root, "bin", "infernoflow.mjs"),
|
|
98
113
|
]) if (fs.existsSync(c)) return c;
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
if (INFERNOFLOW_ROOT) {
|
|
117
|
+
const hit = fromRoot(INFERNOFLOW_ROOT);
|
|
118
|
+
if (hit) return hit;
|
|
119
|
+
}
|
|
120
|
+
for (const candidate of lookupOnPath("infernoflow")) {
|
|
121
|
+
if (!fs.existsSync(candidate)) continue;
|
|
122
|
+
// POSIX: the bin entry is usually a symlink straight to infernoflow.mjs.
|
|
123
|
+
try {
|
|
124
|
+
const real = fs.realpathSync(candidate);
|
|
125
|
+
if (/\.m?js$/i.test(real)) return real;
|
|
126
|
+
} catch {}
|
|
127
|
+
// Windows / wrapper layouts: find the package next to the wrapper.
|
|
128
|
+
const binDir = path.dirname(candidate);
|
|
129
|
+
for (const layout of [
|
|
130
|
+
path.join(binDir, "node_modules", "infernoflow"),
|
|
131
|
+
path.join(binDir, "..", "lib", "node_modules", "infernoflow"),
|
|
132
|
+
]) {
|
|
133
|
+
const hit = fromRoot(layout);
|
|
134
|
+
if (hit) return hit;
|
|
135
|
+
}
|
|
99
136
|
}
|
|
100
|
-
try {
|
|
101
|
-
const lookup = process.platform === "win32" ? "where infernoflow" : "which infernoflow";
|
|
102
|
-
const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
103
|
-
const first = out.split(/\r?\n/)[0];
|
|
104
|
-
if (first && fs.existsSync(first)) return first;
|
|
105
|
-
} catch {}
|
|
106
137
|
return null;
|
|
107
138
|
}
|
|
108
139
|
|
|
@@ -110,7 +141,7 @@ const INFERNOFLOW_BIN = resolveInfernoflowBin();
|
|
|
110
141
|
|
|
111
142
|
// In-process AMP I/O loader. When available, amp_write / amp_read bypass the
|
|
112
143
|
// CLI entirely — no subprocess, no version skew, no flag-mapping field loss.
|
|
113
|
-
// Falls back to
|
|
144
|
+
// Falls back to the CLI via runCli() if the AMP layer can't be loaded.
|
|
114
145
|
let ampIo = null;
|
|
115
146
|
let refreshRuleFiles = null;
|
|
116
147
|
let harvestSnapshot = null;
|
|
@@ -211,8 +242,16 @@ try {
|
|
|
211
242
|
* Run the infernoflow CLI. Returns either the stdout string OR a structured
|
|
212
243
|
* error object so call sites can decide whether to surface it via JSON-RPC
|
|
213
244
|
* sendError() instead of returning gibberish text to the agent.
|
|
245
|
+
*
|
|
246
|
+
* SECURITY: `args` is an ARRAY of separate arguments, passed straight to
|
|
247
|
+
* `node infernoflow.mjs` with execFileSync and no shell. Tool input must never
|
|
248
|
+
* be concatenated into a command string — that allowed command injection
|
|
249
|
+
* through tool arguments (fixed in 0.44.20).
|
|
214
250
|
*/
|
|
215
|
-
function
|
|
251
|
+
function runCli(args, env = {}) {
|
|
252
|
+
if (!Array.isArray(args) || !args.every(a => typeof a === "string")) {
|
|
253
|
+
return { __error: true, message: "internal: runCli expects an array of strings", stderr: "", stdout: "", status: 1 };
|
|
254
|
+
}
|
|
216
255
|
if (!INFERNOFLOW_BIN) {
|
|
217
256
|
return {
|
|
218
257
|
__error: true,
|
|
@@ -223,15 +262,13 @@ function runCmd(args, env = {}) {
|
|
|
223
262
|
};
|
|
224
263
|
}
|
|
225
264
|
try {
|
|
226
|
-
|
|
227
|
-
const cmd = isMjs
|
|
228
|
-
? `"${process.execPath}" "${INFERNOFLOW_BIN}" ${args}`
|
|
229
|
-
: `"${INFERNOFLOW_BIN}" ${args}`;
|
|
230
|
-
return execSync(cmd, {
|
|
265
|
+
return execFileSync(process.execPath, [INFERNOFLOW_BIN, ...args], {
|
|
231
266
|
encoding: "utf8",
|
|
232
267
|
cwd: PROJECT_DIR,
|
|
233
268
|
timeout: 30000,
|
|
234
269
|
env: { ...process.env, ...env },
|
|
270
|
+
windowsHide: true,
|
|
271
|
+
shell: false,
|
|
235
272
|
});
|
|
236
273
|
} catch (err) {
|
|
237
274
|
return {
|
|
@@ -244,7 +281,7 @@ function runCmd(args, env = {}) {
|
|
|
244
281
|
}
|
|
245
282
|
}
|
|
246
283
|
|
|
247
|
-
/** True if a
|
|
284
|
+
/** True if a runCli() result is actually a structured error. */
|
|
248
285
|
function isCmdError(result) {
|
|
249
286
|
return typeof result === "object" && result !== null && result.__error === true;
|
|
250
287
|
}
|
|
@@ -259,36 +296,97 @@ function isCmdError(result) {
|
|
|
259
296
|
// helpers that pair cleanly with the kept CLI surface.
|
|
260
297
|
const TOOLS = [
|
|
261
298
|
// ── AMP-spec memory tools (the product) ──────────────────────────────────
|
|
262
|
-
{ name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string" }, limit: { type: "
|
|
263
|
-
{ name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string" }, file: { type: "string" }, line: { type: "
|
|
264
|
-
{ name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
|
|
265
|
-
{ name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string" }, note: { type: "string", description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
|
|
299
|
+
{ name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string", maxLength: 1000 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string", maxLength: 500 }, limit: { type: "integer", minimum: 1, maximum: 200 } } } },
|
|
300
|
+
{ name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string", maxLength: 2000 }, file: { type: "string", maxLength: 1000 }, line: { type: "integer", minimum: 1, maximum: 10000000 }, tags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 100 } }, detail: { type: "string", maxLength: 200000, description: "Optional rich body (Tier-2). Stored in the consolidated details store; NOT injected into rule files. Put the long-form context here; keep 'msg' to one summary sentence." } }, required: ["type","msg"] } },
|
|
301
|
+
{ name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 500 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
|
|
302
|
+
{ name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string", maxLength: 200 }, note: { type: "string", maxLength: 200000, description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
|
|
266
303
|
{ name: "amp_handoff", description: "AMP: generate the handoff document for the next AI session. format=markdown|json (default: markdown).", inputSchema: { type: "object", properties: { format: { type: "string", enum: ["markdown","json"] } } } },
|
|
267
304
|
{ name: "amp_health", description: "AMP: get the session health score (0-100, A-F grade).", inputSchema: { type: "object", properties: {} } },
|
|
268
305
|
|
|
269
306
|
// ── Read-only contract helpers ───────────────────────────────────────────
|
|
270
307
|
{ name: "infernoflow_status", description: "Show project memory + contract health at a glance.", inputSchema: { type: "object", properties: {} } },
|
|
271
308
|
{ name: "infernoflow_check", description: "Validate the capability contract (read-only).", inputSchema: { type: "object", properties: {} } },
|
|
272
|
-
{ name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
|
|
273
|
-
{ name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes — useful when memory needs branch-aware revalidation.", inputSchema: { type: "object", properties: { sinceCommits: { type: "
|
|
309
|
+
{ name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string", maxLength: 1000 }, working: { type: "string", maxLength: 1000 } } } },
|
|
310
|
+
{ name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes — useful when memory needs branch-aware revalidation.", inputSchema: { type: "object", properties: { sinceCommits: { type: "integer", minimum: 1, maximum: 100, description: "How many commits back to check (default: 1, max: 100)" } } } },
|
|
274
311
|
];
|
|
275
312
|
|
|
313
|
+
// ── Input validation ─────────────────────────────────────────────────────────
|
|
314
|
+
// SECURITY: tool arguments come from the model, and the model can be steered by
|
|
315
|
+
// anything it reads (a README, an issue, a memory entry pulled from git). The
|
|
316
|
+
// inputSchema above is advisory to the client only — nothing enforces it — so
|
|
317
|
+
// we enforce it here, before any argument reaches the CLI, git or the disk.
|
|
318
|
+
// Unknown properties are dropped (not rejected) so a chatty client still works.
|
|
319
|
+
const TOOL_SCHEMAS = new Map(TOOLS.map(t => [t.name, t.inputSchema]));
|
|
320
|
+
|
|
321
|
+
function checkValue(key, v, spec) {
|
|
322
|
+
const t = spec.type;
|
|
323
|
+
if (t === "string") {
|
|
324
|
+
if (typeof v !== "string") return `'${key}' must be a string`;
|
|
325
|
+
if (spec.maxLength && v.length > spec.maxLength) return `'${key}' is too long (max ${spec.maxLength} characters)`;
|
|
326
|
+
if (v.includes("\u0000")) return `'${key}' must not contain NUL characters`;
|
|
327
|
+
} else if (t === "integer" || t === "number") {
|
|
328
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return `'${key}' must be a number`;
|
|
329
|
+
if (t === "integer" && !Number.isInteger(v)) return `'${key}' must be a whole number`;
|
|
330
|
+
if (spec.minimum !== undefined && v < spec.minimum) return `'${key}' must be >= ${spec.minimum}`;
|
|
331
|
+
if (spec.maximum !== undefined && v > spec.maximum) return `'${key}' must be <= ${spec.maximum}`;
|
|
332
|
+
} else if (t === "array") {
|
|
333
|
+
if (!Array.isArray(v)) return `'${key}' must be an array`;
|
|
334
|
+
if (spec.maxItems && v.length > spec.maxItems) return `'${key}' has too many items (max ${spec.maxItems})`;
|
|
335
|
+
for (const item of v) { const e = checkValue(`${key}[]`, item, spec.items || {}); if (e) return e; }
|
|
336
|
+
}
|
|
337
|
+
if (spec.enum && !spec.enum.includes(v)) return `'${key}' must be one of: ${spec.enum.join(", ")}`;
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Returns { ok: true, value } with only known, valid properties, or { ok: false, error }. */
|
|
342
|
+
function validateToolInput(name, input) {
|
|
343
|
+
const schema = TOOL_SCHEMAS.get(name);
|
|
344
|
+
if (!schema) return { ok: false, error: `Unknown tool: ${name}` };
|
|
345
|
+
if (input === undefined || input === null) input = {};
|
|
346
|
+
if (typeof input !== "object" || Array.isArray(input)) return { ok: false, error: "arguments must be an object" };
|
|
347
|
+
const props = schema.properties || {};
|
|
348
|
+
const value = {};
|
|
349
|
+
for (const [key, spec] of Object.entries(props)) {
|
|
350
|
+
const v = input[key];
|
|
351
|
+
if (v === undefined || v === null) continue;
|
|
352
|
+
const err = checkValue(key, v, spec);
|
|
353
|
+
if (err) return { ok: false, error: `Invalid arguments for ${name}: ${err}` };
|
|
354
|
+
value[key] = v;
|
|
355
|
+
}
|
|
356
|
+
for (const req of schema.required || []) {
|
|
357
|
+
if (value[req] === undefined || value[req] === "") return { ok: false, error: `Invalid arguments for ${name}: '${req}' is required` };
|
|
358
|
+
}
|
|
359
|
+
return { ok: true, value };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Free text that is handed to the CLI as an argument. The CLI's own parser
|
|
364
|
+
* treats any argument equal to a flag name (e.g. "--watch", "--auto-push") as
|
|
365
|
+
* that flag, even when it arrives as a separate argv entry. Strip leading
|
|
366
|
+
* dashes so tool text can never be read as a CLI option.
|
|
367
|
+
*/
|
|
368
|
+
function asCliText(v) {
|
|
369
|
+
return String(v ?? "").replace(/^[\s-]+/, "");
|
|
370
|
+
}
|
|
371
|
+
|
|
276
372
|
// ── git drift detection (inline — no external imports in this template file) ─
|
|
277
373
|
function detectGitDrift(sinceCommits) {
|
|
278
374
|
const cwd = PROJECT_DIR;
|
|
279
375
|
const infernoDir = path.join(cwd, "inferno");
|
|
280
376
|
|
|
281
|
-
|
|
282
|
-
|
|
377
|
+
// SECURITY: git is run with an argument array and no shell.
|
|
378
|
+
const runGit = (args) => {
|
|
379
|
+
try { return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false }); }
|
|
283
380
|
catch { return ""; }
|
|
284
381
|
};
|
|
382
|
+
const n = Number.isInteger(sinceCommits) && sinceCommits >= 1 && sinceCommits <= 100 ? sinceCommits : 1;
|
|
285
383
|
|
|
286
384
|
const changedSet = new Set();
|
|
287
385
|
const addLines = (out) => out.split("\n").map(l => l.trim()).filter(Boolean).forEach(f => changedSet.add(f));
|
|
288
386
|
|
|
289
|
-
addLines(runGit("
|
|
290
|
-
addLines(runGit(
|
|
291
|
-
addLines(runGit("
|
|
387
|
+
addLines(runGit(["diff", "--name-only", "HEAD"]));
|
|
388
|
+
addLines(runGit(["diff", "--name-only", `HEAD~${n}`, "HEAD"]));
|
|
389
|
+
addLines(runGit(["ls-files", "--others", "--exclude-standard"]));
|
|
292
390
|
|
|
293
391
|
const changedFiles = Array.from(changedSet).sort();
|
|
294
392
|
if (!changedFiles.length) return "No changed files detected since last commit.";
|
|
@@ -388,29 +486,35 @@ function detectGitDrift(sinceCommits) {
|
|
|
388
486
|
return lines.join("\n");
|
|
389
487
|
}
|
|
390
488
|
|
|
391
|
-
function handleTool(id, name,
|
|
489
|
+
function handleTool(id, name, rawInput) {
|
|
392
490
|
try {
|
|
491
|
+
const checked = validateToolInput(name, rawInput);
|
|
492
|
+
if (!checked.ok) {
|
|
493
|
+
const code = checked.error.startsWith("Unknown tool") ? -32601 : -32602;
|
|
494
|
+
return sendError(id, code, checked.error);
|
|
495
|
+
}
|
|
496
|
+
const input = checked.value;
|
|
393
497
|
let text = "";
|
|
394
498
|
// ── Read-only contract helpers ─────────────────────────────────────────
|
|
395
499
|
if (name === "infernoflow_check") {
|
|
396
|
-
text =
|
|
500
|
+
text = runCli(["check"]);
|
|
397
501
|
} else if (name === "infernoflow_status") {
|
|
398
|
-
text =
|
|
502
|
+
text = runCli(["status"]);
|
|
399
503
|
} else if (name === "infernoflow_context") {
|
|
400
|
-
const
|
|
401
|
-
if (input.intent)
|
|
402
|
-
if (input.working)
|
|
403
|
-
text =
|
|
504
|
+
const args = ["context"];
|
|
505
|
+
if (input.intent) args.push("--intent", asCliText(input.intent));
|
|
506
|
+
if (input.working) args.push("--working", asCliText(input.working));
|
|
507
|
+
text = runCli(args);
|
|
404
508
|
} else if (name === "infernoflow_git_drift") {
|
|
405
|
-
text = detectGitDrift(input.sinceCommits
|
|
509
|
+
text = detectGitDrift(input.sinceCommits ?? 1);
|
|
406
510
|
|
|
407
511
|
// ── AMP-spec memory tools ──────────────────────────────────────────────
|
|
408
512
|
} else if (name === "amp_read") {
|
|
409
|
-
const args = [];
|
|
410
|
-
if (input.query) args.push(
|
|
513
|
+
const args = ["ask"];
|
|
514
|
+
if (input.query) args.push(asCliText(input.query));
|
|
411
515
|
if (input.type) args.push("--type", input.type);
|
|
412
516
|
if (input.limit) args.push("--limit", String(input.limit));
|
|
413
|
-
text =
|
|
517
|
+
text = runCli(args);
|
|
414
518
|
} else if (name === "amp_write") {
|
|
415
519
|
// Prefer in-process write: no subprocess, no `npx` version skew, and
|
|
416
520
|
// file/line/tags reach disk unchanged. The CLI fallback below is only
|
|
@@ -445,15 +549,13 @@ function handleTool(id, name, input) {
|
|
|
445
549
|
return sendError(id, -32000, `amp_write failed (in-process): ${err.message}`);
|
|
446
550
|
}
|
|
447
551
|
} else {
|
|
448
|
-
// Fallback:
|
|
449
|
-
// they're not silently dropped
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (input.
|
|
454
|
-
|
|
455
|
-
if (input.tags && input.tags.length) extras.push("--tags", JSON.stringify(input.tags.join(",")));
|
|
456
|
-
text = runCmd(`log ${m} --type ${t} ${extras.join(" ")}`);
|
|
552
|
+
// Fallback: run the CLI (argument array, no shell). Pass
|
|
553
|
+
// --file/--line/--tags through so they're not silently dropped.
|
|
554
|
+
const args = ["log", asCliText(input.msg), "--type", input.type || "note"];
|
|
555
|
+
if (input.file) args.push("--file", asCliText(input.file));
|
|
556
|
+
if (input.line) args.push("--line", String(input.line));
|
|
557
|
+
if (input.tags && input.tags.length) args.push("--tags", input.tags.map(asCliText).join(","));
|
|
558
|
+
text = runCli(args);
|
|
457
559
|
}
|
|
458
560
|
} else if (name === "amp_bookmark") {
|
|
459
561
|
// A bookmark is a `note` entry tagged "bookmark"; the optional `note`
|
|
@@ -485,13 +587,13 @@ function handleTool(id, name, input) {
|
|
|
485
587
|
return sendError(id, -32000, `amp_bookmark failed (in-process): ${err.message}`);
|
|
486
588
|
}
|
|
487
589
|
} else {
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
text =
|
|
590
|
+
const args = ["bookmark", asCliText(input.label)];
|
|
591
|
+
if (input.note) args.push("--note", asCliText(input.note));
|
|
592
|
+
text = runCli(args);
|
|
491
593
|
}
|
|
492
594
|
} else if (name === "amp_handoff") {
|
|
493
595
|
// switch writes a file; we read it back to return the content
|
|
494
|
-
const switchResult =
|
|
596
|
+
const switchResult = runCli(["switch"]);
|
|
495
597
|
if (isCmdError(switchResult)) {
|
|
496
598
|
return sendError(id, -32000, `infernoflow switch failed: ${switchResult.message}\n${switchResult.stderr || switchResult.stdout || ""}`.trim());
|
|
497
599
|
}
|
|
@@ -508,20 +610,20 @@ function handleTool(id, name, input) {
|
|
|
508
610
|
text = "(handoff generated; could not read back: " + err.message + ")";
|
|
509
611
|
}
|
|
510
612
|
} else if (name === "amp_search") {
|
|
511
|
-
const args = [
|
|
613
|
+
const args = ["ask", asCliText(input.query)];
|
|
512
614
|
if (input.type) args.push("--type", input.type);
|
|
513
|
-
text =
|
|
615
|
+
text = runCli(args);
|
|
514
616
|
} else if (name === "amp_health") {
|
|
515
|
-
const recap =
|
|
617
|
+
const recap = runCli(["recap", "--json"]);
|
|
516
618
|
if (isCmdError(recap)) {
|
|
517
|
-
text =
|
|
619
|
+
text = runCli(["status"]);
|
|
518
620
|
} else {
|
|
519
|
-
text = recap.trim() ||
|
|
621
|
+
text = recap.trim() || runCli(["status"]);
|
|
520
622
|
}
|
|
521
623
|
|
|
522
624
|
} else { return sendError(id, -32601, `Unknown tool: ${name}`); }
|
|
523
625
|
|
|
524
|
-
// Central error check — if any
|
|
626
|
+
// Central error check — if any runCli() call produced a structured error,
|
|
525
627
|
// surface it as a real JSON-RPC error so the calling AI sees a proper
|
|
526
628
|
// failure instead of garbled stderr text mixed into a "successful" reply.
|
|
527
629
|
if (isCmdError(text)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infernoflow",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.20",
|
|
4
4
|
"description": "Persistent memory for AI coding sessions — captures what agents can't infer from code alone. Works with Copilot, Cursor, Claude, and Windsurf.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|