infernoflow 0.33.1 → 0.34.0

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 (84) hide show
  1. package/README.md +208 -120
  2. package/dist/bin/infernoflow.mjs +271 -85
  3. package/dist/lib/adopters/angular.mjs +128 -1
  4. package/dist/lib/adopters/css.mjs +111 -1
  5. package/dist/lib/adopters/react.mjs +104 -1
  6. package/dist/lib/ai/ideDetection.mjs +31 -1
  7. package/dist/lib/ai/localProvider.mjs +88 -1
  8. package/dist/lib/ai/providerRouter.mjs +295 -2
  9. package/dist/lib/commands/adopt.mjs +869 -20
  10. package/dist/lib/commands/adoptWizard.mjs +320 -9
  11. package/dist/lib/commands/agent.mjs +191 -5
  12. package/dist/lib/commands/ai.mjs +407 -2
  13. package/dist/lib/commands/ask.mjs +299 -0
  14. package/dist/lib/commands/audit.mjs +300 -13
  15. package/dist/lib/commands/changelog.mjs +594 -26
  16. package/dist/lib/commands/check.mjs +184 -3
  17. package/dist/lib/commands/ci.mjs +208 -3
  18. package/dist/lib/commands/claudeMd.mjs +139 -28
  19. package/dist/lib/commands/cloud.mjs +521 -5
  20. package/dist/lib/commands/context.mjs +346 -34
  21. package/dist/lib/commands/coverage.mjs +282 -2
  22. package/dist/lib/commands/dashboard.mjs +635 -123
  23. package/dist/lib/commands/demo.mjs +465 -8
  24. package/dist/lib/commands/diff.mjs +274 -5
  25. package/dist/lib/commands/docGate.mjs +81 -2
  26. package/dist/lib/commands/doctor.mjs +321 -3
  27. package/dist/lib/commands/explain.mjs +438 -8
  28. package/dist/lib/commands/export.mjs +239 -10
  29. package/dist/lib/commands/generateSkills.mjs +163 -38
  30. package/dist/lib/commands/graph.mjs +378 -11
  31. package/dist/lib/commands/health.mjs +309 -2
  32. package/dist/lib/commands/impact.mjs +325 -2
  33. package/dist/lib/commands/implement.mjs +103 -7
  34. package/dist/lib/commands/init.mjs +545 -23
  35. package/dist/lib/commands/installCursorHooks.mjs +36 -1
  36. package/dist/lib/commands/installVsCodeCopilotHooks.mjs +37 -1
  37. package/dist/lib/commands/link.mjs +342 -2
  38. package/dist/lib/commands/log.mjs +164 -16
  39. package/dist/lib/commands/monorepo.mjs +428 -4
  40. package/dist/lib/commands/notify.mjs +258 -4
  41. package/dist/lib/commands/onboard.mjs +296 -4
  42. package/dist/lib/commands/prComment.mjs +361 -2
  43. package/dist/lib/commands/prImpact.mjs +157 -2
  44. package/dist/lib/commands/publish.mjs +316 -15
  45. package/dist/lib/commands/recap.mjs +359 -0
  46. package/dist/lib/commands/report.mjs +272 -28
  47. package/dist/lib/commands/review.mjs +223 -9
  48. package/dist/lib/commands/run.mjs +336 -8
  49. package/dist/lib/commands/scaffold.mjs +419 -54
  50. package/dist/lib/commands/scan.mjs +1118 -5
  51. package/dist/lib/commands/scout.mjs +291 -2
  52. package/dist/lib/commands/setup.mjs +310 -5
  53. package/dist/lib/commands/share.mjs +196 -13
  54. package/dist/lib/commands/snapshot.mjs +383 -3
  55. package/dist/lib/commands/stability.mjs +293 -2
  56. package/dist/lib/commands/stats.mjs +402 -0
  57. package/dist/lib/commands/status.mjs +172 -4
  58. package/dist/lib/commands/suggest.mjs +563 -21
  59. package/dist/lib/commands/switch.mjs +310 -9
  60. package/dist/lib/commands/syncAuto.mjs +96 -1
  61. package/dist/lib/commands/synthesize.mjs +228 -10
  62. package/dist/lib/commands/teamSync.mjs +388 -2
  63. package/dist/lib/commands/test.mjs +363 -6
  64. package/dist/lib/commands/theme.mjs +195 -18
  65. package/dist/lib/commands/upgrade.mjs +153 -0
  66. package/dist/lib/commands/version.mjs +282 -2
  67. package/dist/lib/commands/vibe.mjs +357 -7
  68. package/dist/lib/commands/watch.mjs +203 -4
  69. package/dist/lib/commands/why.mjs +358 -4
  70. package/dist/lib/cursorHooksInstall.mjs +60 -1
  71. package/dist/lib/draftToolingInstall.mjs +68 -7
  72. package/dist/lib/git/detect-drift.mjs +208 -4
  73. package/dist/lib/learning/adapt.mjs +101 -6
  74. package/dist/lib/learning/observe.mjs +119 -1
  75. package/dist/lib/learning/patternDetector.mjs +298 -1
  76. package/dist/lib/learning/profile.mjs +279 -2
  77. package/dist/lib/learning/skillSynthesizer.mjs +145 -24
  78. package/dist/lib/templates/index.mjs +131 -1
  79. package/dist/lib/theme/scanner.mjs +343 -4
  80. package/dist/lib/ui/errors.mjs +142 -1
  81. package/dist/lib/ui/output.mjs +72 -6
  82. package/dist/lib/ui/prompts.mjs +147 -6
  83. package/dist/lib/vsCodeCopilotHooksInstall.mjs +42 -1
  84. package/package.json +1 -1
@@ -1,2 +1,407 @@
1
- import*as g from"node:fs";import*as k from"node:path";import*as x from"node:https";import*as C from"node:http";import*as R from"node:readline";import{bold as d,cyan as v,gray as i,green as r,yellow as y,red as h}from"../ui/output.mjs";function O(n){return k.join(n,"inferno")}function A(n){const o=k.join(O(n),"integrations.json");if(!g.existsSync(o))return{};try{return JSON.parse(g.readFileSync(o,"utf8"))}catch{return{}}}function E(n,o){const t=O(n);g.existsSync(t)||g.mkdirSync(t,{recursive:!0}),g.writeFileSync(k.join(t,"integrations.json"),JSON.stringify(o,null,2)+`
2
- `)}const m=[{id:"anthropic",name:"Anthropic (Claude)",envKey:"ANTHROPIC_API_KEY",models:["claude-sonnet-4-6","claude-opus-4-6","claude-haiku-4-5-20251001"],default:"claude-sonnet-4-6",keyHint:"sk-ant-api03-\u2026",docsUrl:"https://console.anthropic.com/settings/keys"},{id:"openai",name:"OpenAI (GPT)",envKey:"OPENAI_API_KEY",models:["gpt-4o","gpt-4o-mini","gpt-4-turbo"],default:"gpt-4o",keyHint:"sk-\u2026",docsUrl:"https://platform.openai.com/api-keys"},{id:"gemini",name:"Google Gemini",envKey:"GOOGLE_AI_API_KEY",models:["gemini-2.0-flash","gemini-1.5-pro","gemini-1.5-flash"],default:"gemini-2.0-flash",keyHint:"AIza\u2026",docsUrl:"https://aistudio.google.com/app/apikey"},{id:"openrouter",name:"OpenRouter",envKey:"OPENROUTER_API_KEY",models:["anthropic/claude-sonnet-4-6","openai/gpt-4o","meta-llama/llama-3.1-8b-instruct:free"],default:"anthropic/claude-sonnet-4-6",keyHint:"sk-or-\u2026",docsUrl:"https://openrouter.ai/keys"},{id:"ollama",name:"Ollama (local)",envKey:null,models:["llama3.2","mistral","codellama","phi3"],default:"llama3.2",keyHint:null,docsUrl:"https://ollama.com"}];function _(n){return new Promise(o=>{const t=new URL(n),e=(t.protocol==="https:"?x:C).request({hostname:t.hostname,port:t.port||(t.protocol==="https:"?443:80),path:t.pathname+(t.search||""),method:"GET",timeout:5e3},s=>{let a="";s.on("data",c=>a+=c),s.on("end",()=>{try{o({status:s.statusCode,body:JSON.parse(a)})}catch{o({status:s.statusCode,body:a})}})});e.on("error",()=>o(null)),e.on("timeout",()=>{e.destroy(),o(null)}),e.end()})}async function b(n,o){const l={anthropic:process.env.ANTHROPIC_API_KEY,openai:process.env.OPENAI_API_KEY,gemini:process.env.GOOGLE_AI_API_KEY||process.env.GEMINI_API_KEY,openrouter:process.env.OPENROUTER_API_KEY}[n],e=o[n]?.apiKey,s=l||e,a=l?"env":e?"integrations.json":null,c=o[n]?.model||m.find(u=>u.id===n)?.default;if(n==="ollama"){const u=await _("http://localhost:11434/api/tags").catch(()=>null);if(u?.status===200){const p=u.body?.models?.map(f=>f.name)||[];return{configured:!0,source:"local",model:o.ollama?.model||"llama3.2",available:!0,models:p}}return{configured:!1,source:null,model:null,available:!1}}return{configured:!!s,source:a,model:c,available:null,masked:s?s.slice(0,8)+"\u2026":null}}async function N(n,o,t){try{const{callAI:l}=await import("../ai/providerRouter.mjs"),e=`Reply with exactly: "infernoflow AI test OK \u2014 ${n}"`;return await l(e,t,n)}catch{return null}}function $(n,o){return new Promise(t=>n.question(o,t))}async function U(n){const o=A(n);console.log(),console.log(` ${d("infernoflow ai")} ${i("\u2014 provider status")}`),console.log();let t=!1;for(const l of m){const e=await b(l.id,o);e.configured&&(t=!0);const s=e.configured?r("\u2713"):i("\u25CB"),a=d(l.name.padEnd(22)),c=e.configured?`${r("configured")} ${i(e.source)} ${i("model: "+e.model)}${e.masked?" "+i(e.masked):""}`:i("not configured");console.log(` ${s} ${a} ${c}`)}console.log(),t?console.log(` ${i("Run")} ${v("infernoflow ai test")} ${i("to verify the active provider.")}`):(console.log(` ${y("No AI providers configured.")} Run: ${v("infernoflow ai setup")}`),console.log(` ${i("Without a provider, explain/why/review use structural fallbacks.")}`)),console.log()}async function G(n){const o=A(n),t={anthropic:process.env.ANTHROPIC_API_KEY,openai:process.env.OPENAI_API_KEY,gemini:process.env.GOOGLE_AI_API_KEY||process.env.GEMINI_API_KEY,openrouter:process.env.OPENROUTER_API_KEY};console.log(),console.log(` ${d("\u{1F525} infernoflow ai setup")}`),console.log(` ${i("Connect an AI provider for explain, why, review, and changelog.")}`),console.log(),m.forEach((e,s)=>{const a=t[e.id],c=o[e.id]?.apiKey,u=a?r(" \u2713 key detected in environment"):c?r(" \u2713 key already saved"):"",p=d(String(s+1)),f=e.id==="ollama"?i(" (local, no key needed)"):"";console.log(` ${p}) ${d(e.name.padEnd(22))}${f}${u}`)}),console.log();const l=R.createInterface({input:process.stdin,output:process.stdout});try{const e=await $(l," Select provider [1]: "),s=(parseInt(e.trim())||1)-1;if(s<0||s>=m.length){console.log(h(` Invalid choice. Enter a number 1\u2013${m.length}.`));return}const a=m[s],c=a.id;if(console.log(),console.log(` ${d(a.name)}`),c==="ollama"){const p=await $(l," Ollama host [http://localhost:11434]: "),f=await $(l,` Model [${a.default}]: `);o.ollama={host:p.trim()||"http://localhost:11434",model:f.trim()||a.default},E(n,o),console.log(),process.stdout.write(` ${r("\u2713")} Saved. Testing connection\u2026 `),(await _(`${o.ollama.host}/api/tags`).catch(()=>null))?.status===200?console.log(r("OK")):(console.log(y("not reachable")),console.log(` ${y("\u26A0")} Start Ollama first: ${v("ollama serve")}`))}else{const p=t[c],f=o[c]?.apiKey,I=p||f;if(I){const w=p?"environment variable":"saved config";if(console.log(` ${r("\u2713")} API key detected from ${w}: ${i(I.slice(0,12)+"\u2026")}`),(await $(l," Use this key? [Y/n]: ")).trim().toLowerCase()==="n"){console.log(),a.docsUrl&&console.log(` ${i("Get a key at:")} ${v(a.docsUrl)}`);const K=await $(l," Paste new API key: ");if(!K.trim()){console.log(h(" No key provided. Exiting."));return}o[c]={apiKey:K.trim(),model:o[c]?.model||a.default}}else o[c]={apiKey:I,model:o[c]?.model||a.default}}else{console.log(` ${i("Get your API key at:")} ${v(a.docsUrl)}`),console.log(` ${i("Tip: paste the key below \u2014 it starts with")} ${i(a.keyHint)}`),console.log();const w=await $(l," Paste API key: ");if(!w.trim()){console.log(h(" No key provided. Exiting."));return}o[c]={apiKey:w.trim(),model:a.default}}const P=o[c].model;console.log(),console.log(` ${i("Available models:")} ${a.models.join(" ")}`);const S=await $(l,` Model [${P}]: `);o[c].model=S.trim()||P,E(n,o),console.log(),process.stdout.write(` ${r("\u2713")} Saved. Testing connection\u2026 `),(await N(c,o,n))?.text?console.log(r("OK")+i(` (${o[c].model})`)):(console.log(y("no response")),console.log(` ${y("\u26A0")} Connection failed \u2014 double-check your API key.`))}console.log(),console.log(` ${r("\u2713")} ${d(a.name)} is ready.`),console.log(` ${i("AI-powered commands:")} explain why review changelog`),console.log();const u=k.join(n,".gitignore");g.existsSync(u)&&(g.readFileSync(u,"utf8").includes("integrations.json")||(console.log(` ${y("\u26A0")} Add ${v("inferno/integrations.json")} to your .gitignore to avoid committing your API key.`),console.log()))}finally{l.close()}}async function Y(n,o){const t=A(o),l=n.find(s=>!s.startsWith("--"))||null;console.log(),console.log(` ${d("infernoflow ai test")}`),console.log();const e=l?m.filter(s=>s.id===l):m;for(const s of e){if(!(await b(s.id,t)).configured){console.log(` ${i("\u25CB")} ${d(s.name.padEnd(22))} ${i("not configured \u2014 skipping")}`);continue}process.stdout.write(` ${y("\u2026")} ${d(s.name.padEnd(22))} testing\u2026 `);const c=await N(s.id,t,o);c?.text?(console.log(r("OK")+i(` (${c.model||s.id})`)),console.log(` ${i(c.text.trim().slice(0,80))}`)):(console.log(h("FAIL")),console.log(` ${h("No response \u2014 check API key or model name")}`))}console.log()}async function T(n,o){const t=A(o),l=n.find(e=>!e.startsWith("--"));if(l||(console.error(h("\u2717 Usage: infernoflow ai clear <provider>")),console.error(i(" Example: infernoflow ai clear openai")),process.exit(1)),!t[l]){console.log(i(` No config found for "${l}"`));return}delete t[l],E(o,t),console.log(r(` \u2713 Cleared config for ${l}`))}async function L(n){const o=(n||[]).slice(1),t=o.find(s=>!s.startsWith("--"))||"status",l=o.filter(s=>s!==t),e=process.cwd();switch(t){case"setup":return G(e);case"status":return U(e);case"test":return Y(l,e);case"clear":return T(l,e);default:console.error(h(`\u2717 Unknown subcommand: "${t}"`)),console.error(i(" Usage: infernoflow ai <setup|status|test|clear>")),process.exit(1)}}export{L as aiCommand};
1
+ /**
2
+ * infernoflow ai
3
+ *
4
+ * Manage AI provider configuration for infernoflow commands
5
+ * (explain, why, review, changelog, etc.)
6
+ *
7
+ * Subcommands:
8
+ * infernoflow ai setup Interactive guided setup
9
+ * infernoflow ai status Show configured providers and which is active
10
+ * infernoflow ai test [provider] Send a test prompt and show response
11
+ * infernoflow ai clear [provider] Remove a provider's API key from config
12
+ *
13
+ * Config is stored in inferno/integrations.json (project-scoped).
14
+ * API keys can also come from environment variables (checked first).
15
+ *
16
+ * Supported providers:
17
+ * anthropic ANTHROPIC_API_KEY claude-sonnet-4-6
18
+ * openai OPENAI_API_KEY gpt-4o
19
+ * gemini GOOGLE_AI_API_KEY gemini-2.0-flash
20
+ * openrouter OPENROUTER_API_KEY (any model)
21
+ * ollama (local, no key) llama3.2
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import * as https from "node:https";
27
+ import * as http from "node:http";
28
+ import * as readline from "node:readline";
29
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
30
+
31
+ // ── config helpers ────────────────────────────────────────────────────────────
32
+
33
+ function infernoDir(cwd) { return path.join(cwd, "inferno"); }
34
+
35
+ function loadConfig(cwd) {
36
+ const p = path.join(infernoDir(cwd), "integrations.json");
37
+ if (!fs.existsSync(p)) return {};
38
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
39
+ }
40
+
41
+ function saveConfig(cwd, config) {
42
+ const dir = infernoDir(cwd);
43
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(path.join(dir, "integrations.json"), JSON.stringify(config, null, 2) + "\n");
45
+ }
46
+
47
+ // ── provider definitions ──────────────────────────────────────────────────────
48
+
49
+ const PROVIDERS = [
50
+ {
51
+ id: "anthropic",
52
+ name: "Anthropic (Claude)",
53
+ envKey: "ANTHROPIC_API_KEY",
54
+ models: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"],
55
+ default: "claude-sonnet-4-6",
56
+ keyHint: "sk-ant-api03-…",
57
+ docsUrl: "https://console.anthropic.com/settings/keys",
58
+ },
59
+ {
60
+ id: "openai",
61
+ name: "OpenAI (GPT)",
62
+ envKey: "OPENAI_API_KEY",
63
+ models: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
64
+ default: "gpt-4o",
65
+ keyHint: "sk-…",
66
+ docsUrl: "https://platform.openai.com/api-keys",
67
+ },
68
+ {
69
+ id: "gemini",
70
+ name: "Google Gemini",
71
+ envKey: "GOOGLE_AI_API_KEY",
72
+ models: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
73
+ default: "gemini-2.0-flash",
74
+ keyHint: "AIza…",
75
+ docsUrl: "https://aistudio.google.com/app/apikey",
76
+ },
77
+ {
78
+ id: "openrouter",
79
+ name: "OpenRouter",
80
+ envKey: "OPENROUTER_API_KEY",
81
+ models: ["anthropic/claude-sonnet-4-6", "openai/gpt-4o", "meta-llama/llama-3.1-8b-instruct:free"],
82
+ default: "anthropic/claude-sonnet-4-6",
83
+ keyHint: "sk-or-…",
84
+ docsUrl: "https://openrouter.ai/keys",
85
+ },
86
+ {
87
+ id: "ollama",
88
+ name: "Ollama (local)",
89
+ envKey: null,
90
+ models: ["llama3.2", "mistral", "codellama", "phi3"],
91
+ default: "llama3.2",
92
+ keyHint: null,
93
+ docsUrl: "https://ollama.com",
94
+ },
95
+ ];
96
+
97
+ // ── HTTP probe ────────────────────────────────────────────────────────────────
98
+
99
+ function httpGet(url) {
100
+ return new Promise((resolve) => {
101
+ const parsed = new URL(url);
102
+ const lib = parsed.protocol === "https:" ? https : http;
103
+ const req = lib.request({ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: parsed.pathname + (parsed.search || ""), method: "GET", timeout: 5000 }, (res) => {
104
+ let raw = "";
105
+ res.on("data", d => (raw += d));
106
+ res.on("end", () => { try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode, body: raw }); } });
107
+ });
108
+ req.on("error", () => resolve(null));
109
+ req.on("timeout", () => { req.destroy(); resolve(null); });
110
+ req.end();
111
+ });
112
+ }
113
+
114
+ // ── provider status check ─────────────────────────────────────────────────────
115
+
116
+ async function checkProviderStatus(providerId, config) {
117
+ const envMap = {
118
+ anthropic: process.env.ANTHROPIC_API_KEY,
119
+ openai: process.env.OPENAI_API_KEY,
120
+ gemini: process.env.GOOGLE_AI_API_KEY || process.env.GEMINI_API_KEY,
121
+ openrouter: process.env.OPENROUTER_API_KEY,
122
+ };
123
+
124
+ const fromEnv = envMap[providerId];
125
+ const fromConfig = config[providerId]?.apiKey;
126
+ const key = fromEnv || fromConfig;
127
+ const source = fromEnv ? "env" : fromConfig ? "integrations.json" : null;
128
+ const model = config[providerId]?.model || PROVIDERS.find(p => p.id === providerId)?.default;
129
+
130
+ if (providerId === "ollama") {
131
+ // Check if Ollama is running
132
+ const probe = await httpGet("http://localhost:11434/api/tags").catch(() => null);
133
+ if (probe?.status === 200) {
134
+ const models = probe.body?.models?.map(m => m.name) || [];
135
+ return { configured: true, source: "local", model: config.ollama?.model || "llama3.2", available: true, models };
136
+ }
137
+ return { configured: false, source: null, model: null, available: false };
138
+ }
139
+
140
+ return { configured: !!key, source, model, available: null, masked: key ? key.slice(0, 8) + "…" : null };
141
+ }
142
+
143
+ // ── ai test prompt ────────────────────────────────────────────────────────────
144
+
145
+ async function testProvider(providerId, config, cwd) {
146
+ try {
147
+ const { callAI } = await import("../ai/providerRouter.mjs");
148
+ const testPrompt = `Reply with exactly: "infernoflow AI test OK — ${providerId}"`;
149
+ const result = await callAI(testPrompt, cwd, providerId);
150
+ return result;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ // ── readline helper ───────────────────────────────────────────────────────────
157
+
158
+ function prompt(rl, question) {
159
+ return new Promise(resolve => rl.question(question, resolve));
160
+ }
161
+
162
+ // ── subcommands ───────────────────────────────────────────────────────────────
163
+
164
+ async function cmdStatus(cwd) {
165
+ const config = loadConfig(cwd);
166
+
167
+ console.log();
168
+ console.log(` ${bold("infernoflow ai")} ${gray("— provider status")}`);
169
+ console.log();
170
+
171
+ let anyConfigured = false;
172
+
173
+ for (const p of PROVIDERS) {
174
+ const status = await checkProviderStatus(p.id, config);
175
+ if (status.configured) anyConfigured = true;
176
+
177
+ const icon = status.configured ? green("✓") : gray("○");
178
+ const label = bold(p.name.padEnd(22));
179
+ const info = status.configured
180
+ ? `${green("configured")} ${gray(status.source)} ${gray("model: " + status.model)}${status.masked ? " " + gray(status.masked) : ""}`
181
+ : gray("not configured");
182
+ console.log(` ${icon} ${label} ${info}`);
183
+ }
184
+
185
+ console.log();
186
+
187
+ if (!anyConfigured) {
188
+ console.log(` ${yellow("No AI providers configured.")} Run: ${cyan("infernoflow ai setup")}`);
189
+ console.log(` ${gray("Without a provider, explain/why/review use structural fallbacks.")}`);
190
+ } else {
191
+ console.log(` ${gray("Run")} ${cyan("infernoflow ai test")} ${gray("to verify the active provider.")}`);
192
+ }
193
+ console.log();
194
+ }
195
+
196
+ async function cmdSetup(cwd) {
197
+ const config = loadConfig(cwd);
198
+
199
+ // Check which providers already have keys (env vars or saved config)
200
+ const envKeys = {
201
+ anthropic: process.env.ANTHROPIC_API_KEY,
202
+ openai: process.env.OPENAI_API_KEY,
203
+ gemini: process.env.GOOGLE_AI_API_KEY || process.env.GEMINI_API_KEY,
204
+ openrouter: process.env.OPENROUTER_API_KEY,
205
+ };
206
+
207
+ console.log();
208
+ console.log(` ${bold("🔥 infernoflow ai setup")}`);
209
+ console.log(` ${gray("Connect an AI provider for explain, why, review, and changelog.")}`);
210
+ console.log();
211
+
212
+ // Numbered menu
213
+ PROVIDERS.forEach((p, i) => {
214
+ const envKey = envKeys[p.id];
215
+ const savedKey = config[p.id]?.apiKey;
216
+ const detected = envKey ? green(" ✓ key detected in environment") :
217
+ savedKey ? green(" ✓ key already saved") : "";
218
+ const num = bold(String(i + 1));
219
+ const local = p.id === "ollama" ? gray(" (local, no key needed)") : "";
220
+ console.log(` ${num}) ${bold(p.name.padEnd(22))}${local}${detected}`);
221
+ });
222
+
223
+ console.log();
224
+
225
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
226
+
227
+ try {
228
+ // Numbered selection
229
+ const choice = await prompt(rl, ` Select provider [1]: `);
230
+ const idx = (parseInt(choice.trim()) || 1) - 1;
231
+
232
+ if (idx < 0 || idx >= PROVIDERS.length) {
233
+ console.log(red(` Invalid choice. Enter a number 1–${PROVIDERS.length}.`));
234
+ return;
235
+ }
236
+
237
+ const provider = PROVIDERS[idx];
238
+ const providerId = provider.id;
239
+
240
+ console.log();
241
+ console.log(` ${bold(provider.name)}`);
242
+
243
+ if (providerId === "ollama") {
244
+ // Ollama — no key needed
245
+ const hostInput = await prompt(rl, ` Ollama host [http://localhost:11434]: `);
246
+ const modelInput = await prompt(rl, ` Model [${provider.default}]: `);
247
+
248
+ config.ollama = {
249
+ host: hostInput.trim() || "http://localhost:11434",
250
+ model: modelInput.trim() || provider.default,
251
+ };
252
+ saveConfig(cwd, config);
253
+
254
+ console.log();
255
+ process.stdout.write(` ${green("✓")} Saved. Testing connection… `);
256
+ const probe = await httpGet(`${config.ollama.host}/api/tags`).catch(() => null);
257
+ if (probe?.status === 200) {
258
+ console.log(green("OK"));
259
+ } else {
260
+ console.log(yellow("not reachable"));
261
+ console.log(` ${yellow("⚠")} Start Ollama first: ${cyan("ollama serve")}`);
262
+ }
263
+
264
+ } else {
265
+ // API key provider
266
+ const envKey = envKeys[providerId];
267
+ const savedKey = config[providerId]?.apiKey;
268
+ const existing = envKey || savedKey;
269
+
270
+ if (existing) {
271
+ // Key already detected — confirm or replace
272
+ const source = envKey ? "environment variable" : "saved config";
273
+ console.log(` ${green("✓")} API key detected from ${source}: ${gray(existing.slice(0, 12) + "…")}`);
274
+ const useIt = await prompt(rl, ` Use this key? [Y/n]: `);
275
+ if (useIt.trim().toLowerCase() === "n") {
276
+ console.log();
277
+ if (provider.docsUrl) console.log(` ${gray("Get a key at:")} ${cyan(provider.docsUrl)}`);
278
+ const keyInput = await prompt(rl, ` Paste new API key: `);
279
+ if (!keyInput.trim()) { console.log(red(" No key provided. Exiting.")); return; }
280
+ config[providerId] = { apiKey: keyInput.trim(), model: config[providerId]?.model || provider.default };
281
+ } else {
282
+ // Use existing key — just confirm/update model
283
+ config[providerId] = { apiKey: existing, model: config[providerId]?.model || provider.default };
284
+ }
285
+ } else {
286
+ // No key found — ask for it
287
+ console.log(` ${gray("Get your API key at:")} ${cyan(provider.docsUrl)}`);
288
+ console.log(` ${gray("Tip: paste the key below — it starts with")} ${gray(provider.keyHint)}`);
289
+ console.log();
290
+ const keyInput = await prompt(rl, ` Paste API key: `);
291
+ if (!keyInput.trim()) { console.log(red(" No key provided. Exiting.")); return; }
292
+ config[providerId] = { apiKey: keyInput.trim(), model: provider.default };
293
+ }
294
+
295
+ // Model selection (just press Enter to keep default)
296
+ const currentModel = config[providerId].model;
297
+ console.log();
298
+ console.log(` ${gray("Available models:")} ${provider.models.join(" ")}`);
299
+ const modelInput = await prompt(rl, ` Model [${currentModel}]: `);
300
+ config[providerId].model = modelInput.trim() || currentModel;
301
+
302
+ saveConfig(cwd, config);
303
+
304
+ console.log();
305
+ process.stdout.write(` ${green("✓")} Saved. Testing connection… `);
306
+
307
+ const result = await testProvider(providerId, config, cwd);
308
+ if (result?.text) {
309
+ console.log(green("OK") + gray(` (${config[providerId].model})`));
310
+ } else {
311
+ console.log(yellow("no response"));
312
+ console.log(` ${yellow("⚠")} Connection failed — double-check your API key.`);
313
+ }
314
+ }
315
+
316
+ console.log();
317
+ console.log(` ${green("✓")} ${bold(provider.name)} is ready.`);
318
+ console.log(` ${gray("AI-powered commands:")} explain why review changelog`);
319
+ console.log();
320
+
321
+ // gitignore reminder
322
+ const gitignorePath = path.join(cwd, ".gitignore");
323
+ if (fs.existsSync(gitignorePath)) {
324
+ const content = fs.readFileSync(gitignorePath, "utf8");
325
+ if (!content.includes("integrations.json")) {
326
+ console.log(` ${yellow("⚠")} Add ${cyan("inferno/integrations.json")} to your .gitignore to avoid committing your API key.`);
327
+ console.log();
328
+ }
329
+ }
330
+
331
+ } finally {
332
+ rl.close();
333
+ }
334
+ }
335
+
336
+ async function cmdTest(args, cwd) {
337
+ const config = loadConfig(cwd);
338
+ const providerId = args.find(a => !a.startsWith("--")) || null;
339
+
340
+ console.log();
341
+ console.log(` ${bold("infernoflow ai test")}`);
342
+ console.log();
343
+
344
+ const toTest = providerId
345
+ ? PROVIDERS.filter(p => p.id === providerId)
346
+ : PROVIDERS;
347
+
348
+ for (const p of toTest) {
349
+ const status = await checkProviderStatus(p.id, config);
350
+ if (!status.configured) {
351
+ console.log(` ${gray("○")} ${bold(p.name.padEnd(22))} ${gray("not configured — skipping")}`);
352
+ continue;
353
+ }
354
+
355
+ process.stdout.write(` ${yellow("…")} ${bold(p.name.padEnd(22))} testing… `);
356
+ const result = await testProvider(p.id, config, cwd);
357
+ if (result?.text) {
358
+ console.log(green("OK") + gray(` (${result.model || p.id})`));
359
+ console.log(` ${gray(result.text.trim().slice(0, 80))}`);
360
+ } else {
361
+ console.log(red("FAIL"));
362
+ console.log(` ${red("No response — check API key or model name")}`);
363
+ }
364
+ }
365
+
366
+ console.log();
367
+ }
368
+
369
+ async function cmdClear(args, cwd) {
370
+ const config = loadConfig(cwd);
371
+ const providerId = args.find(a => !a.startsWith("--"));
372
+
373
+ if (!providerId) {
374
+ console.error(red("✗ Usage: infernoflow ai clear <provider>"));
375
+ console.error(gray(" Example: infernoflow ai clear openai"));
376
+ process.exit(1);
377
+ }
378
+
379
+ if (!config[providerId]) {
380
+ console.log(gray(` No config found for "${providerId}"`));
381
+ return;
382
+ }
383
+
384
+ delete config[providerId];
385
+ saveConfig(cwd, config);
386
+ console.log(green(` ✓ Cleared config for ${providerId}`));
387
+ }
388
+
389
+ // ── entry point ───────────────────────────────────────────────────────────────
390
+
391
+ export async function aiCommand(rawArgs) {
392
+ const args = (rawArgs || []).slice(1);
393
+ const sub = args.find(a => !a.startsWith("--")) || "status";
394
+ const subArgs = args.filter(a => a !== sub);
395
+ const cwd = process.cwd();
396
+
397
+ switch (sub) {
398
+ case "setup": return cmdSetup(cwd);
399
+ case "status": return cmdStatus(cwd);
400
+ case "test": return cmdTest(subArgs, cwd);
401
+ case "clear": return cmdClear(subArgs, cwd);
402
+ default:
403
+ console.error(red(`✗ Unknown subcommand: "${sub}"`));
404
+ console.error(gray(" Usage: infernoflow ai <setup|status|test|clear>"));
405
+ process.exit(1);
406
+ }
407
+ }