infernoflow 0.19.0 → 0.21.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.
@@ -45,6 +45,12 @@ const COMMAND_DESCRIPTIONS = {
45
45
  monorepo: "Manage infernoflow across monorepo packages (init | list | status | diff | sync)",
46
46
  link: "Link capabilities to Jira, Linear, or GitHub Issues tickets",
47
47
  audit: "Classify capabilities by sensitivity (auth, payment, PII, admin) and generate security surface map",
48
+ scout: "Scan source files for undocumented capabilities not yet in the contract",
49
+ export: "Export contract to OpenAPI, Backstage catalog-info.yaml, CSV, or Markdown",
50
+ snapshot: "Save/diff/restore named snapshots of the capability contract",
51
+ health: "Compute a 0–100 health score across coverage, docs, freshness, completeness, drift",
52
+ vibe: "Vibe coding mode — watches files, auto-syncs contract, regenerates context on every save",
53
+ adopt: "Interactive wizard to adopt infernoflow in an existing project (detect → review → wire up)",
48
54
  };
49
55
 
50
56
  const COMMAND_HANDLERS = {
@@ -82,7 +88,13 @@ const COMMAND_HANDLERS = {
82
88
  report: async (args) => (await import("../lib/commands/report.mjs")).reportCommand(args),
83
89
  monorepo: async (args) => (await import("../lib/commands/monorepo.mjs")).monorepoCommand(args),
84
90
  link: async (args) => (await import("../lib/commands/link.mjs")).linkCommand(args),
85
- audit: async (args) => (await import("../lib/commands/audit.mjs")).auditCommand(args),
91
+ audit: async (args) => (await import("../lib/commands/audit.mjs")).auditCommand(args),
92
+ scout: async (args) => (await import("../lib/commands/scout.mjs")).scoutCommand(args),
93
+ export: async (args) => (await import("../lib/commands/export.mjs")).exportCommand(args),
94
+ snapshot: async (args) => (await import("../lib/commands/snapshot.mjs")).snapshotCommand(args),
95
+ health: async (args) => (await import("../lib/commands/health.mjs")).healthCommand(args),
96
+ vibe: async (args) => (await import("../lib/commands/vibe.mjs")).vibeCommand(args),
97
+ adopt: async (args) => (await import("../lib/commands/adoptWizard.mjs")).adoptWizardCommand(args),
86
98
  };
87
99
 
88
100
  function formatCommandsHelp() {
@@ -279,6 +291,54 @@ ${formatCommandsHelp()}
279
291
  --fail-on high|medium Exit 1 if unreviewed caps at given severity exist
280
292
  --json Machine-readable output
281
293
 
294
+ ${bold("vibe options:")}
295
+ --dir <dirs> Comma-separated directories to watch (default: auto-detected)
296
+ --no-suggest Disable automatic contract sync on file save
297
+ --no-context Disable CONTEXT.md regeneration
298
+ --interval <secs> Debounce seconds between saves (default: 4)
299
+ --port <n> Also run a mini status dashboard on localhost:<n>
300
+ --silent Suppress all terminal output (pure background mode)
301
+
302
+ ${bold("adopt options:")}
303
+ --dir <dirs> Source directories to scan (default: src,lib,app,api,routes,controllers)
304
+ --yes, -y Auto-approve all candidates (non-interactive)
305
+ --json Machine-readable output, implies --yes
306
+
307
+ ${bold("init --template options:")}
308
+ --template rest-api REST API (Express/Fastify/Hono) starter
309
+ --template nextjs Next.js fullstack app starter
310
+ --template cli CLI tool (Node.js/Python) starter
311
+ --template graphql GraphQL API (Apollo/Pothos) starter
312
+ --template monorepo Monorepo workspace starter
313
+
314
+ ${bold("scout options:")}
315
+ --dir <dirs> Comma-separated directories to scan (default: src,lib,app,api,routes)
316
+ --apply Write discovered capabilities to the contract file
317
+ --min-confidence <0-1> Minimum confidence threshold (default: 0.6)
318
+ --json Machine-readable output
319
+
320
+ ${bold("export options:")}
321
+ --format openapi|backstage|csv|markdown|json Output format (required)
322
+ --out <path> Output file path (default: project root, auto-named)
323
+ --json Machine-readable summary
324
+
325
+ ${bold("snapshot sub-commands:")}
326
+ save <name> Save current contract as a named snapshot
327
+ list List all snapshots
328
+ show <name> Print a snapshot's capabilities
329
+ diff <name1> [<name2>] Diff two snapshots (omit name2 to diff against current)
330
+ restore <name> Overwrite contract with snapshot contents
331
+ delete <name> Delete a snapshot
332
+
333
+ ${bold("snapshot options:")}
334
+ --json Machine-readable output
335
+
336
+ ${bold("health options:")}
337
+ --fail-below <score> Exit 1 if health score is below this threshold (CI gate)
338
+ --watch Re-run every 30s (live terminal view)
339
+ --interval <secs> Watch interval in seconds (default: 30)
340
+ --json Machine-readable score + breakdown
341
+
282
342
  ${bold("Machine output:")}
283
343
  ${gray("status --json")}
284
344
  ${gray("check --json")}
@@ -0,0 +1,320 @@
1
+ /**
2
+ * infernoflow adopt
3
+ *
4
+ * Interactive adoption wizard for existing projects.
5
+ * Takes a project from zero to fully wired in one guided flow.
6
+ *
7
+ * Steps:
8
+ * 1. Detect project type, framework, language
9
+ * 2. Scout capabilities in source files
10
+ * 3. Interactive approve / rename / skip each candidate
11
+ * 4. Write capabilities.json + contract.json
12
+ * 5. Generate starter scenarios
13
+ * 6. Install git hooks
14
+ * 7. Regenerate CONTEXT.md
15
+ * 8. Show health score and next steps
16
+ *
17
+ * Usage:
18
+ * infernoflow adopt Full interactive wizard
19
+ * infernoflow adopt --yes Auto-approve all (non-interactive)
20
+ * infernoflow adopt --dir src Custom source directory
21
+ * infernoflow adopt --json Machine-readable, implies --yes
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import * as readline from "node:readline";
27
+ import { spawnSync } from "node:child_process";
28
+ import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
29
+
30
+ // ── Detection ─────────────────────────────────────────────────────────────────
31
+
32
+ function detectProject(cwd) {
33
+ const has = (...files) => files.some(f => fs.existsSync(path.join(cwd, f)));
34
+ const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(cwd, f), "utf8")); } catch { return {}; } };
35
+
36
+ const pkg = has("package.json") ? read("package.json") : {};
37
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
38
+
39
+ let framework = "unknown", lang = "javascript", type = "backend";
40
+
41
+ if (has("tsconfig.json")) lang = "typescript";
42
+ if (has("Pipfile","pyproject.toml","requirements.txt")) lang = "python";
43
+ if (has("Gemfile")) lang = "ruby";
44
+ if (has("go.mod")) lang = "go";
45
+ if (has("Cargo.toml")) lang = "rust";
46
+
47
+ if (deps["next"]) { framework = "nextjs"; type = "fullstack"; }
48
+ else if (deps["nuxt"]) { framework = "nuxt"; type = "fullstack"; }
49
+ else if (deps["react"]) { framework = "react"; type = "frontend"; }
50
+ else if (deps["vue"]) { framework = "vue"; type = "frontend"; }
51
+ else if (deps["@angular/core"]) { framework = "angular"; type = "frontend"; }
52
+ else if (deps["express"]) { framework = "express"; type = "backend"; }
53
+ else if (deps["fastify"]) { framework = "fastify"; type = "backend"; }
54
+ else if (deps["hono"]) { framework = "hono"; type = "backend"; }
55
+ else if (deps["@nestjs/core"]) { framework = "nestjs"; type = "backend"; }
56
+ else if (deps["graphql"]) { framework = "graphql"; type = "backend"; }
57
+ else if (has("config/routes.rb")) { framework = "rails"; type = "fullstack"; }
58
+ else if (has("manage.py")) { framework = "django"; type = "backend"; }
59
+
60
+ if (pkg.bin || has("bin/")) type = "cli";
61
+ if (has("nx.json","turbo.json","pnpm-workspace.yaml")) type = "monorepo";
62
+
63
+ return {
64
+ name: pkg.name || path.basename(cwd),
65
+ version: pkg.version || "0.1.0",
66
+ description: pkg.description || "",
67
+ lang, framework, type,
68
+ };
69
+ }
70
+
71
+ // ── Scout ─────────────────────────────────────────────────────────────────────
72
+
73
+ const SRC_EXTS = new Set([".js",".mjs",".cjs",".ts",".tsx",".jsx",".py",".rb"]);
74
+ const SKIP_DIRS = new Set(["node_modules",".git","dist","build",".next","__pycache__","vendor","coverage","inferno"]);
75
+
76
+ const PATTERNS = [
77
+ {
78
+ regex: /(?:app|router|server)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
79
+ extract: m => ({ id: routeId(m[2], m[1]), hint: `${m[1].toUpperCase()} ${m[2]}`, confidence: 0.85 }),
80
+ },
81
+ {
82
+ filePattern: /[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/,
83
+ extract: fp => {
84
+ const m = fp.match(/[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/);
85
+ if (!m) return null;
86
+ const r = m[2].replace(/[/\\]index$/, "").replace(/\[([^\]]+)\]/g, ":$1");
87
+ return { id: routeId("/" + r, "api"), hint: `Next.js API: /${r}`, confidence: 0.9 };
88
+ },
89
+ },
90
+ {
91
+ regex: /export\s+(?:async\s+)?(?:function|class|const)\s+([A-Z][A-Za-z0-9]+)/g,
92
+ extract: m => ({ id: camelId(m[1]), hint: `export ${m[1]}`, confidence: 0.65 }),
93
+ },
94
+ ];
95
+
96
+ function routeId(r, method) {
97
+ const id = r.replace(/^\//, "").replace(/\/:?[^/]+/g, "").replace(/\//g, "-")
98
+ .replace(/[^a-zA-Z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
99
+ return id || method.toLowerCase() + "-root";
100
+ }
101
+ function camelId(name) {
102
+ return name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "").replace(/-+/g, "-");
103
+ }
104
+
105
+ function* walk(dir) {
106
+ if (!fs.existsSync(dir)) return;
107
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
108
+ if (SKIP_DIRS.has(e.name)) continue;
109
+ const full = path.join(dir, e.name);
110
+ if (e.isDirectory()) yield* walk(full);
111
+ else if (e.isFile() && SRC_EXTS.has(path.extname(e.name))) yield full;
112
+ }
113
+ }
114
+
115
+ function scout(dirs, cwd) {
116
+ const seen = new Map();
117
+ const add = (id, r, file) => {
118
+ if (!seen.has(id) || r.confidence > seen.get(id).confidence)
119
+ seen.set(id, { ...r, source: path.relative(cwd, file) });
120
+ };
121
+
122
+ for (const dir of dirs) {
123
+ for (const file of walk(dir)) {
124
+ for (const pat of PATTERNS) {
125
+ if (pat.filePattern && pat.filePattern.test(file)) {
126
+ const r = pat.extract(file);
127
+ if (r && r.id.length >= 3) add(r.id, r, file);
128
+ }
129
+ }
130
+ let content;
131
+ try { content = fs.readFileSync(file, "utf8"); } catch { continue; }
132
+ for (const pat of PATTERNS) {
133
+ if (!pat.regex) continue;
134
+ pat.regex.lastIndex = 0;
135
+ let m;
136
+ while ((m = pat.regex.exec(content)) !== null) {
137
+ const r = pat.extract(m);
138
+ if (r && r.id.length >= 3 && !["index","app","main","root","default","handler"].includes(r.id))
139
+ add(r.id, r, file);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ return [...seen.values()].filter(c => c.confidence >= 0.5).sort((a, b) => b.confidence - a.confidence);
145
+ }
146
+
147
+ // ── Interactive review ────────────────────────────────────────────────────────
148
+
149
+ function ask(rl, q) { return new Promise(r => rl.question(q, r)); }
150
+
151
+ async function review(candidates, autoYes) {
152
+ if (autoYes) return candidates.filter(c => c.confidence >= 0.6);
153
+
154
+ const approved = [];
155
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
156
+
157
+ console.log();
158
+ console.log(` ${bold("Review candidates")} ${cyan("y")} approve · ${yellow("e")} edit · ${red("n")} skip · ${gray("a")} approve all · ${gray("q")} stop`);
159
+ console.log();
160
+
161
+ for (let i = 0; i < candidates.length; i++) {
162
+ const c = candidates[i];
163
+ const conf = `${Math.round(c.confidence * 100)}%`;
164
+ const col = c.confidence >= 0.8 ? green : c.confidence >= 0.65 ? yellow : gray;
165
+ process.stdout.write(
166
+ ` [${i + 1}/${candidates.length}] ${bold(c.id.padEnd(30))} ${col(conf)} ${gray(c.hint)}\n` +
167
+ ` ${gray(c.source)}\n > `
168
+ );
169
+
170
+ const ans = (await ask(rl, "")).trim().toLowerCase();
171
+ if (ans === "q") break;
172
+ if (ans === "a") {
173
+ for (let j = i; j < candidates.length; j++) approved.push(candidates[j]);
174
+ break;
175
+ }
176
+ if (ans === "n") { console.log(` ${gray("skipped")}\n`); continue; }
177
+
178
+ let id = c.id;
179
+ if (ans === "e") {
180
+ const newId = (await ask(rl, ` New id (${c.id}): `)).trim();
181
+ if (newId && /^[a-z0-9-_]+$/.test(newId)) id = newId;
182
+ }
183
+ approved.push({ ...c, id });
184
+ console.log(` ${green("✔")} ${bold(id)}\n`);
185
+ }
186
+
187
+ rl.close();
188
+ return approved;
189
+ }
190
+
191
+ // ── Writers ───────────────────────────────────────────────────────────────────
192
+
193
+ function writeFiles(infernoDir, approved, project) {
194
+ // contract.json
195
+ const cp = path.join(infernoDir, "contract.json");
196
+ const existing = fs.existsSync(cp) ? JSON.parse(fs.readFileSync(cp, "utf8")) : {};
197
+ fs.writeFileSync(cp, JSON.stringify({
198
+ ...existing,
199
+ policyId: existing.policyId || project.name.replace(/[^a-zA-Z0-9-]/g, "-"),
200
+ policyVersion: existing.policyVersion || 1,
201
+ capabilities: approved.map(c => c.id),
202
+ }, null, 2) + "\n");
203
+
204
+ // capabilities.json
205
+ fs.writeFileSync(path.join(infernoDir, "capabilities.json"), JSON.stringify({
206
+ capabilities: approved.map(c => ({
207
+ id: c.id, description: c.hint,
208
+ since: new Date().toISOString().slice(0, 10), source: "adopt",
209
+ })),
210
+ }, null, 2) + "\n");
211
+
212
+ // Starter scenarios
213
+ const scenDir = path.join(infernoDir, "scenarios");
214
+ if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
215
+ for (const c of approved) {
216
+ const sp = path.join(scenDir, `${c.id}.json`);
217
+ if (fs.existsSync(sp)) continue;
218
+ fs.writeFileSync(sp, JSON.stringify({
219
+ id: `${c.id}-happy-path`, capability: c.id,
220
+ description: `Happy path for ${c.description || c.id}`,
221
+ steps: [{ action: "invoke", target: c.id, input: {} }, { action: "assert", field: "status", value: "success" }],
222
+ capabilitiesCovered: [c.id],
223
+ }, null, 2) + "\n");
224
+ }
225
+ }
226
+
227
+ function runCli(args, cwd) {
228
+ return spawnSync("infernoflow", args, {
229
+ cwd, encoding: "utf8", timeout: 30_000,
230
+ stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" },
231
+ });
232
+ }
233
+
234
+ // ── Entry ─────────────────────────────────────────────────────────────────────
235
+
236
+ export async function adoptWizardCommand(rawArgs) {
237
+ const args = rawArgs.slice(1);
238
+ const autoYes = args.includes("--yes") || args.includes("-y") || args.includes("--json");
239
+ const jsonMode = args.includes("--json");
240
+ const cwd = process.cwd();
241
+
242
+ const dirIdx = args.indexOf("--dir");
243
+ const dirs = dirIdx !== -1
244
+ ? args[dirIdx + 1].split(",").map(d => path.resolve(cwd, d.trim()))
245
+ : ["src","lib","app","api","pages","routes","controllers","services","handlers"]
246
+ .map(d => path.join(cwd, d)).filter(d => fs.existsSync(d));
247
+ if (!dirs.length) dirs.push(cwd);
248
+
249
+ const project = detectProject(cwd);
250
+ const infernoDir = path.join(cwd, "inferno");
251
+ if (!fs.existsSync(infernoDir)) {
252
+ fs.mkdirSync(infernoDir, { recursive: true });
253
+ fs.mkdirSync(path.join(infernoDir, "scenarios"), { recursive: true });
254
+ }
255
+
256
+ if (!jsonMode) {
257
+ console.log();
258
+ console.log(` ${bold("🔥 infernoflow adopt")} ${gray("— existing project wizard")}`);
259
+ console.log();
260
+ console.log(` ${bold("Project:")} ${cyan(project.name)} v${project.version}`);
261
+ console.log(` ${bold("Language:")} ${project.lang} ${bold("Framework:")} ${project.framework} ${bold("Type:")} ${project.type}`);
262
+ console.log();
263
+ info(`Scanning ${dirs.map(d => path.relative(cwd, d) || ".").join(", ")} …`);
264
+ }
265
+
266
+ const candidates = scout(dirs, cwd);
267
+
268
+ if (!candidates.length) {
269
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities detected." })); return; }
270
+ warn("No capabilities detected. Use --dir src,lib,api");
271
+ return;
272
+ }
273
+
274
+ if (!jsonMode) {
275
+ console.log();
276
+ console.log(` ${bold(`${candidates.length} capability candidates detected`)}`);
277
+ }
278
+
279
+ const approved = await review(candidates, autoYes);
280
+
281
+ if (!approved.length) {
282
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities approved." })); return; }
283
+ warn("Nothing approved — exiting.");
284
+ return;
285
+ }
286
+
287
+ writeFiles(infernoDir, approved, project);
288
+ runCli(["context"], cwd);
289
+
290
+ // Offer hooks in interactive mode
291
+ let hooksInstalled = false;
292
+ if (!autoYes) {
293
+ const rl3 = readline.createInterface({ input: process.stdin, output: process.stdout });
294
+ const ans = (await ask(rl3, `\n Install git hooks for auto-sync? ${gray("[Y/n]")} `)).trim().toLowerCase();
295
+ rl3.close();
296
+ if (ans !== "n") { runCli(["setup", "--yes"], cwd); hooksInstalled = true; }
297
+ } else {
298
+ runCli(["setup", "--yes"], cwd); hooksInstalled = true;
299
+ }
300
+
301
+ if (jsonMode) {
302
+ console.log(JSON.stringify({ ok: true, project, detected: candidates.length, approved: approved.length, capabilities: approved.map(c => c.id), hooksInstalled }));
303
+ return;
304
+ }
305
+
306
+ console.log();
307
+ done(`${bold(String(approved.length))} capabilities adopted`);
308
+ console.log();
309
+ console.log(` ${green("✔")} ${bold("inferno/contract.json")} written`);
310
+ console.log(` ${green("✔")} ${bold("inferno/capabilities.json")} written`);
311
+ console.log(` ${green("✔")} ${bold("inferno/scenarios/")} scaffolded`);
312
+ console.log(` ${green("✔")} ${bold("inferno/CONTEXT.md")} generated`);
313
+ if (hooksInstalled) console.log(` ${green("✔")} Git hooks installed`);
314
+ console.log();
315
+ console.log(` ${bold("Next steps:")}`);
316
+ console.log(` ${cyan("1.")} Paste ${bold("inferno/CONTEXT.md")} into Claude or Cursor`);
317
+ console.log(` ${cyan("2.")} Run ${bold("infernoflow vibe")} to keep everything in sync automatically`);
318
+ console.log(` ${cyan("3.")} Run ${bold("infernoflow health")} to see your starting score`);
319
+ console.log();
320
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * infernoflow export
3
+ *
4
+ * Export the capability contract to external formats so it can travel
5
+ * outside the repo — into API docs, service catalogs, spreadsheets, wikis.
6
+ *
7
+ * Formats:
8
+ * openapi OpenAPI 3.1 JSON (stubs for each capability)
9
+ * backstage Backstage catalog-info.yaml
10
+ * csv Spreadsheet-ready CSV
11
+ * markdown Confluence/Notion-ready Markdown table
12
+ * json Clean JSON (normalised, no internal infernoflow fields)
13
+ *
14
+ * Usage:
15
+ * infernoflow export --format openapi
16
+ * infernoflow export --format backstage --out catalog-info.yaml
17
+ * infernoflow export --format csv --out capabilities.csv
18
+ * infernoflow export --format markdown
19
+ * infernoflow export --format json --out contract-export.json
20
+ */
21
+
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { done, warn, info, bold, cyan, gray } from "../ui/output.mjs";
25
+
26
+ // ── Contract reader ───────────────────────────────────────────────────────────
27
+
28
+ function readContract(infernoDir) {
29
+ for (const f of ["contract.json", "capabilities.json"]) {
30
+ const p = path.join(infernoDir, f);
31
+ if (!fs.existsSync(p)) continue;
32
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function normaliseCaps(contract) {
38
+ const raw = contract?.capabilities || [];
39
+ return raw.map(c => {
40
+ if (typeof c === "string") return { id: c, description: "", tags: [], status: "active" };
41
+ return {
42
+ id: c.id || c.name || "unknown",
43
+ description: c.description || "",
44
+ tags: c.tags || [],
45
+ status: c.status || "active",
46
+ since: c.since || "",
47
+ owner: c.owner || "",
48
+ };
49
+ });
50
+ }
51
+
52
+ function readMeta(infernoDir) {
53
+ const pkgPath = path.join(path.dirname(infernoDir), "package.json");
54
+ try {
55
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
56
+ return { name: pkg.name || "my-service", version: pkg.version || "0.0.0", description: pkg.description || "" };
57
+ } catch {
58
+ return { name: path.basename(path.dirname(infernoDir)), version: "0.0.0", description: "" };
59
+ }
60
+ }
61
+
62
+ // ── Formatters ────────────────────────────────────────────────────────────────
63
+
64
+ function toOpenApi(caps, meta) {
65
+ const paths = {};
66
+ for (const cap of caps) {
67
+ const route = `/${cap.id.replace(/_/g, "-")}`;
68
+ paths[route] = {
69
+ get: {
70
+ operationId: cap.id,
71
+ summary: cap.description || cap.id,
72
+ tags: cap.tags.length ? cap.tags : [meta.name],
73
+ parameters: [],
74
+ responses: {
75
+ "200": { description: "Success", content: { "application/json": { schema: { type: "object" } } } },
76
+ "401": { description: "Unauthorized" },
77
+ "500": { description: "Internal Server Error" },
78
+ },
79
+ },
80
+ };
81
+ }
82
+
83
+ return JSON.stringify({
84
+ openapi: "3.1.0",
85
+ info: {
86
+ title: meta.name,
87
+ version: meta.version,
88
+ description: meta.description || `Capability contract for ${meta.name}`,
89
+ },
90
+ paths,
91
+ components: { schemas: {}, securitySchemes: {} },
92
+ }, null, 2);
93
+ }
94
+
95
+ function toBackstage(caps, meta) {
96
+ const tags = [...new Set(caps.flatMap(c => c.tags))].slice(0, 10);
97
+ const lines = [
98
+ `apiVersion: backstage.io/v1alpha1`,
99
+ `kind: Component`,
100
+ `metadata:`,
101
+ ` name: ${meta.name}`,
102
+ ` description: "${(meta.description || meta.name).replace(/"/g, '\\"')}"`,
103
+ ` annotations:`,
104
+ ` infernoflow/capability-count: "${caps.length}"`,
105
+ ` infernoflow/generated-at: "${new Date().toISOString()}"`,
106
+ tags.length ? ` tags:\n${tags.map(t => ` - ${t}`).join("\n")}` : "",
107
+ `spec:`,
108
+ ` type: service`,
109
+ ` lifecycle: production`,
110
+ ` owner: team-default`,
111
+ ` providesApis: []`,
112
+ `---`,
113
+ `# Capabilities`,
114
+ ...caps.map(c => [
115
+ `# ${c.id}`,
116
+ `# ${c.description || "(no description)"}`,
117
+ `# tags: ${c.tags.join(", ") || "none"}`,
118
+ `# status: ${c.status}`,
119
+ ].join("\n")),
120
+ ].filter(Boolean);
121
+ return lines.join("\n") + "\n";
122
+ }
123
+
124
+ function toCsv(caps) {
125
+ const header = ["id", "description", "tags", "status", "since", "owner"];
126
+ const rows = caps.map(c => [
127
+ c.id,
128
+ (c.description || "").replace(/"/g, '""'),
129
+ c.tags.join("|"),
130
+ c.status,
131
+ c.since,
132
+ c.owner,
133
+ ].map(v => `"${v}"`).join(","));
134
+ return [header.join(","), ...rows].join("\n") + "\n";
135
+ }
136
+
137
+ function toMarkdown(caps, meta) {
138
+ const lines = [
139
+ `# ${meta.name} — Capability Contract`,
140
+ ``,
141
+ `> Generated by infernoflow on ${new Date().toISOString().slice(0, 10)}`,
142
+ `> ${caps.length} capabilities tracked`,
143
+ ``,
144
+ `| Capability | Description | Tags | Status |`,
145
+ `|---|---|---|---|`,
146
+ ...caps.map(c =>
147
+ `| \`${c.id}\` | ${c.description || ""} | ${c.tags.join(", ") || "—"} | ${c.status} |`
148
+ ),
149
+ ``,
150
+ ];
151
+ return lines.join("\n");
152
+ }
153
+
154
+ function toCleanJson(caps, meta) {
155
+ return JSON.stringify({
156
+ name: meta.name,
157
+ version: meta.version,
158
+ exportedAt: new Date().toISOString(),
159
+ capabilities: caps,
160
+ }, null, 2) + "\n";
161
+ }
162
+
163
+ // ── Entry ─────────────────────────────────────────────────────────────────────
164
+
165
+ const FORMAT_EXT = {
166
+ openapi: "openapi.json",
167
+ backstage: "catalog-info.yaml",
168
+ csv: "capabilities.csv",
169
+ markdown: "capabilities.md",
170
+ json: "capabilities-export.json",
171
+ };
172
+
173
+ export async function exportCommand(rawArgs) {
174
+ const args = rawArgs.slice(1);
175
+ const jsonMode = args.includes("--json");
176
+ const cwd = process.cwd();
177
+ const infernoDir = path.join(cwd, "inferno");
178
+
179
+ if (!fs.existsSync(infernoDir)) {
180
+ const msg = "inferno/ not found. Run: infernoflow init";
181
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
182
+ else warn(msg);
183
+ process.exit(1);
184
+ }
185
+
186
+ const fmtIdx = args.indexOf("--format");
187
+ const format = fmtIdx !== -1 ? args[fmtIdx + 1] : null;
188
+ const outIdx = args.indexOf("--out");
189
+ const outArg = outIdx !== -1 ? args[outIdx + 1] : null;
190
+
191
+ const validFormats = Object.keys(FORMAT_EXT);
192
+
193
+ if (!format || !validFormats.includes(format)) {
194
+ const msg = `Usage: infernoflow export --format <${validFormats.join("|")}> [--out <path>]`;
195
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
196
+ else {
197
+ warn(msg);
198
+ console.log();
199
+ console.log(` ${gray("Available formats:")}`);
200
+ console.log(` ${bold("openapi")} — OpenAPI 3.1 JSON with a stub path per capability`);
201
+ console.log(` ${bold("backstage")} — Backstage catalog-info.yaml component definition`);
202
+ console.log(` ${bold("csv")} — Spreadsheet-ready CSV`);
203
+ console.log(` ${bold("markdown")} — Confluence/Notion table`);
204
+ console.log(` ${bold("json")} — Clean normalised JSON`);
205
+ console.log();
206
+ }
207
+ return;
208
+ }
209
+
210
+ const contract = readContract(infernoDir);
211
+ if (!contract) {
212
+ const msg = "No contract.json or capabilities.json found.";
213
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
214
+ else warn(msg);
215
+ process.exit(1);
216
+ }
217
+
218
+ const caps = normaliseCaps(contract);
219
+ const meta = readMeta(infernoDir);
220
+ const outPath = outArg || path.join(cwd, FORMAT_EXT[format]);
221
+
222
+ let output;
223
+ switch (format) {
224
+ case "openapi": output = toOpenApi(caps, meta); break;
225
+ case "backstage": output = toBackstage(caps, meta); break;
226
+ case "csv": output = toCsv(caps); break;
227
+ case "markdown": output = toMarkdown(caps, meta); break;
228
+ case "json": output = toCleanJson(caps, meta); break;
229
+ }
230
+
231
+ fs.writeFileSync(outPath, output);
232
+
233
+ if (jsonMode) {
234
+ console.log(JSON.stringify({ ok: true, format, file: outPath, capabilities: caps.length }));
235
+ } else {
236
+ done(`Exported ${bold(String(caps.length))} capabilities → ${cyan(path.relative(cwd, outPath))}`);
237
+ console.log();
238
+ }
239
+ }