diff --git a/src/cli.js b/src/cli.js index 18de17e..ba90612 100755 --- a/src/cli.js +++ b/src/cli.js @@ -24,6 +24,7 @@ const COMMANDS = { reuse: "proof-carrying code cache — query / mint --file / stats", context: "budgeted context assembly + completeness gate — what an edit NEEDS known", preflight: "assumption check — what a task names that the repo doesn't define", + config: "provider setup — show / switch / add providers, set default model", route: "recommend the cheapest capable model for a task (+ gateway config)", impact: "predict blast radius for a symbol or file from the atlas graph", substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify", @@ -67,13 +68,23 @@ async function run(argv) { } if (cmd === "init") { const { init } = await import("./init.js"); - const { report, bytes } = init({ targetRoot: process.cwd() }); + const noSettings = argv.includes("--no-settings"); + const { report, bytes, settings } = init({ targetRoot: process.cwd(), noSettings }); const wrote = report.filter((r) => r.action === "written").map((r) => r.target); console.log(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`); console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); console.log( ` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``, ); + if (settings?.action === "merged") { + console.log( + ` settings: merged ${settings.added.join(", ")} into ${settings.path}`, + ); + } else if (settings?.action === "unchanged") { + console.log(` settings: already up to date (${settings.path})`); + } else if (settings?.action === "skipped") { + console.log(" settings: skipped (--no-settings)"); + } console.log(` active: tools · crew · guards → \`${BRAND.cli} catalog\``); console.log(` verify: \`${BRAND.cli} doctor\``); return; @@ -678,9 +689,19 @@ async function run(argv) { } console.log(out.trim()); } catch { - console.log( - " ccusage not found. Install for real spend (reads local JSONL, nothing leaves your machine):\n npm i -g ccusage # then: forge cost", - ); + const { estimateSpendFromLogs } = await import("./cost_report.js"); + const est = estimateSpendFromLogs(); + if (est && est.totalCost > 0) { + console.log(` $${est.totalCost.toFixed(2)} estimated from Claude session logs (${est.sessions} session(s))`); + if (est.byModel.length) { + for (const m of est.byModel) console.log(` ${m.model.padEnd(30)} $${m.cost.toFixed(4)} (${m.inTokens} in / ${m.outTokens} out)`); + } + console.log("\n install ccusage for precise tracking: npm i -g ccusage"); + } else { + console.log( + " ccusage not found. Install for real spend (reads local JSONL, nothing leaves your machine):\n npm i -g ccusage # then: forge cost", + ); + } } console.log( `\n ceiling: FORGE_COST_CEILING (default $10) — the cost-budget guard warns when a day exceeds it.`, @@ -832,6 +853,75 @@ async function run(argv) { console.log(json ? JSON.stringify(r, null, 2) : renderSubstrate(r)); return; } + if (cmd === "config") { + const sub = argv[1] || "show"; + const { loadProviders, activeProvider, setProvider, addProvider, listProviders, applyRoute } = + await import("./providers.js"); + const json = argv.includes("--json"); + if (sub === "show") { + const prov = activeProvider(process.cwd()); + const config = loadProviders(process.cwd()); + if (json) return console.log(JSON.stringify({ active: config.active, provider: prov }, null, 2)); + console.log(`${BRAND.brand} config\n`); + console.log(` provider: ${prov.name} (${prov.label || prov.name})`); + console.log(` base URL: ${prov.baseUrl}`); + console.log(` env key: ${prov.envKey || "(none)"}${prov.envKey ? (process.env[prov.envKey] ? " ✓ set" : " ✗ not set") : ""}`); + console.log(` models:`); + for (const [tier, id] of Object.entries(prov.models || {})) + console.log(` ${tier.padEnd(8)} ${id}`); + return; + } + if (sub === "providers") { + const list = listProviders(process.cwd()); + if (json) return console.log(JSON.stringify(list, null, 2)); + console.log(`${BRAND.brand} config providers\n`); + for (const p of list) + console.log( + ` ${p.active ? "▸" : " "} ${p.name.padEnd(14)} ${p.label.padEnd(20)} ${p.envKey ? (p.hasKey ? "✓ key set" : "✗ key missing") : ""}`, + ); + console.log(`\n switch: \`${BRAND.cli} config provider \``); + return; + } + if (sub === "provider") { + const name = argv[2]; + if (!name) { + console.error(`usage: ${BRAND.cli} config provider | ${BRAND.cli} config provider add --base-url [--key-env ]`); + process.exitCode = 1; + return; + } + if (name === "add") { + const addName = argv[3]; + const flagVal = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; }; + const baseUrl = flagVal("--base-url"); + const envKey = flagVal("--key-env"); + const label = flagVal("--label"); + const r = addProvider(process.cwd(), addName, { baseUrl, envKey, label }); + if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; return; } + console.log(` added provider "${addName}" → ${r.provider.baseUrl}`); + return; + } + const r = setProvider(process.cwd(), name); + if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; return; } + console.log(` switched to provider "${name}" (${r.provider.label || name})`); + return; + } + if (sub === "model") { + const tier = argv[2]; + if (!tier) { + console.error(`usage: ${BRAND.cli} config model `); + process.exitCode = 1; + return; + } + const r = applyRoute(tier); + if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; return; } + console.log(` model set to ${r.model} (${r.modelId})${r.prev ? ` — was: ${r.prev}` : ""}`); + console.log(` written to ${r.path}`); + return; + } + console.error(`config: unknown subcommand "${sub}" (show | providers | provider | model )`); + process.exitCode = 1; + return; + } if (cmd === "route") { const r = await import("./route.js"); if (argv[1] === "gateway") { @@ -844,35 +934,53 @@ async function run(argv) { return; } const json = argv.includes("--json"); + const apply = argv.includes("--apply"); + const providerIdx = argv.indexOf("--provider"); + const providerName = providerIdx >= 0 ? argv[providerIdx + 1] : undefined; + const FLAGS = new Set(["--json", "--apply"]); const task = argv .slice(1) - .filter((a) => a !== "--json") + .filter((a, i) => !FLAGS.has(a) && a !== "--provider" && argv[i] !== "--provider") .join(" "); if (!task) { - console.error('usage: forge route "" [--json] | forge route gateway'); + console.error('usage: forge route "" [--apply] [--provider ] [--json] | forge route gateway'); process.exitCode = 1; return; } + if (providerName) { + const { setProvider } = await import("./providers.js"); + const sr = setProvider(process.cwd(), providerName); + if (!sr.ok) { console.error(` ${sr.reason}`); process.exitCode = 1; return; } + } const rec = r.routeTask(process.cwd(), task); - // P8 route metering — explicit CLI path only (hooks that route stay write-free); - // best-effort, a metrics failure must never block the recommendation. r.meterRoute(process.cwd(), task, rec); if (json) { console.log(JSON.stringify(rec, null, 2)); - return; + } else { + console.log(`${BRAND.brand} route — cheapest capable model\n`); + console.log( + ` → ${rec.model.name} (${rec.tier}, $${rec.model.inCost}/$${rec.model.outCost} per M tok)`, + ); + console.log(` ${rec.model.use}`); + console.log( + ` complexity ${rec.score.toFixed(2)}${rec.reasons.length ? ` · driven by: ${rec.reasons.join(", ")}` : ""}`, + ); + console.log( + ` signals: ${rec.signals.files} file(s), fan-out ${rec.signals.fanout}, churn ${rec.signals.churn}, past-mistakes ${rec.signals.pastMistakes}, ambiguity ${rec.signals.ambiguity.toFixed(2)}`, + ); + } + if (apply) { + const { applyRoute } = await import("./providers.js"); + const ar = applyRoute(rec.key); + if (ar.ok) { + if (!json) console.log(`\n applied: model set to ${ar.model} (${ar.modelId}) in ${ar.path}`); + } else { + if (!json) console.error(`\n apply failed: ${ar.reason}`); + process.exitCode = 1; + } + } else if (!json) { + console.log(`\n advisory · apply: \`${BRAND.cli} route "" --apply\` · gateway: \`${BRAND.cli} route gateway\``); } - console.log(`${BRAND.brand} route — cheapest capable model\n`); - console.log( - ` → ${rec.model.name} (${rec.tier}, $${rec.model.inCost}/$${rec.model.outCost} per M tok)`, - ); - console.log(` ${rec.model.use}`); - console.log( - ` complexity ${rec.score.toFixed(2)}${rec.reasons.length ? ` · driven by: ${rec.reasons.join(", ")}` : ""}`, - ); - console.log( - ` signals: ${rec.signals.files} file(s), fan-out ${rec.signals.fanout}, churn ${rec.signals.churn}, past-mistakes ${rec.signals.pastMistakes}, ambiguity ${rec.signals.ambiguity.toFixed(2)}`, - ); - console.log("\n advisory · auto-routing: `forge route gateway`"); return; } if (cmd === "anchor") { diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index 0576e89..9bba9c7 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -82,8 +82,15 @@ async function main() { // allowBuild:false keeps it cheap and never writes .forge/ from a hook; advisory only. if (typeof hook.prompt === "string" && hook.prompt.trim()) { const result = substrateCheck(root, hook.prompt, { allowBuild: false }); - // Opt-in enforcing mode (FORGE_ENFORCE=1) turns the gate from advisory into a real halt on - // a vacuous prompt. Default: advisory, never blocks. + // Best-effort metrics recording — fills the cost dashboard pipeline without + // blocking the hook. A failing write is silently swallowed. + try { + const { record } = await import("./metrics.js"); + record(root, { stage: "gate", outcome: result.gate?.halted ? "halt" : "pass" }); + if (result.route?.key) { + record(root, { stage: "route", tier: result.route.tier }); + } + } catch {} const gate = enforceDecision(result); if (gate.block) { process.stdout.write(JSON.stringify({ decision: "block", reason: gate.reason })); diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index e47c1dc..895eeb2 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -12,6 +12,10 @@ import { routeTask } from "./route.js"; import { decompose } from "./scope.js"; import { predictImpact, substrateCheck } from "./substrate.js"; import { epochDay } from "./util.js"; +import { report as costReport } from "./cost_report.js"; +import { dashData } from "./dash.js"; +import { diagnose } from "./diagnose.js"; +import { doctor } from "./doctor.js"; const PKG_VERSION = (() => { try { @@ -114,9 +118,57 @@ const TOOLS = [ required: ["files"], }, }, + { + name: "forge_cost", + description: + "Cost report — measured stage factors (gate, cache, route, context) from .forge/metrics.jsonl with multiplicative composition.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "forge_dash_data", + description: + "Dashboard JSON payload — ledger stats, metrics, atlas info. Same data forge dash serves at /api/data.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "forge_brain", + description: + "Project memory index — list all remembered facts stored in .forge/brain/.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "forge_ledger_query", + description: + "Query the proof-carrying memory ledger with a natural language query. Returns ranked matching claims.", + inputSchema: { + type: "object", + properties: { query: { type: "string", description: "what you are about to do or looking for" } }, + required: ["query"], + }, + }, + { + name: "forge_diagnose", + description: + "Doom-loop check — record a failure and check if the same signature has recurred (3x = escalation). Prevents thrashing.", + inputSchema: { + type: "object", + properties: { + errorText: { type: "string", description: "the error message" }, + file: { type: "string", description: "file where the error occurred" }, + symbol: { type: "string", description: "symbol involved" }, + }, + required: ["errorText"], + }, + }, + { + name: "forge_doctor", + description: + "Health check — verify installed tools, guards, MCP auth, config drift, and system state.", + inputSchema: { type: "object", properties: {} }, + }, ]; -function callTool(name, args = {}) { +async function callTool(name, args = {}) { if (name === "cortex_lessons") { const files = args.files ?? []; const symbols = args.symbols ?? []; @@ -151,11 +203,50 @@ function callTool(name, args = {}) { const d = decompose(root, args.files ?? []); return JSON.stringify(d, null, 2); } + if (name === "forge_cost") return JSON.stringify(costReport(root), null, 2); + if (name === "forge_dash_data") return JSON.stringify(dashData(root), null, 2); + if (name === "forge_brain") { + try { + const { brainStore, list, buildIndex } = await import("./brain.js"); + const store = brainStore(root); + const idx = buildIndex(store); + const items = list(store); + return JSON.stringify({ items, indexed: idx.indexed, overflow: idx.overflow }, null, 2); + } catch { return "No brain store found — run `forge remember` to start."; } + } + if (name === "forge_ledger_query") { + try { + const { loadClaims, repoLedger } = await import("./ledger_store.js"); + const { retrieve, claimText } = await import("./ledger.js"); + const { claimSim, simLabel } = await import("./embed.js"); + const dir = repoLedger(root); + const q = String(args.query ?? ""); + const claims = loadClaims(dir); + const sim = claimSim(root, q, claims, claimText); + const ranked = retrieve(q, claims, { nowDay: today(), budget: 8, sim }); + return JSON.stringify({ + sim: simLabel(sim), + results: ranked.map((r) => ({ id: r.claim.id, kind: r.claim.kind, score: r.score, text: claimText(r.claim).slice(0, 200) })), + }, null, 2); + } catch { return "No ledger claims found."; } + } + if (name === "forge_diagnose") { + const r = diagnose(root, { + errorText: String(args.errorText ?? ""), + file: args.file, + symbol: args.symbol, + }); + return JSON.stringify(r, null, 2); + } + if (name === "forge_doctor") { + const { results, failed } = doctor({ targetRoot: root }); + return JSON.stringify({ results, failed }, null, 2); + } return null; } /** Handle one JSON-RPC message; returns a response object, or null for notifications. */ -export function handle(msg) { +export async function handle(msg) { const { id, method, params } = msg; if (id === undefined || String(method).startsWith("notifications/")) return null; if (method === "initialize") { @@ -171,7 +262,7 @@ export function handle(msg) { } if (method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; if (method === "tools/call") { - const text = callTool(params?.name, params?.arguments); + const text = await callTool(params?.name, params?.arguments); if (text === null) { return { jsonrpc: "2.0", @@ -203,8 +294,9 @@ export function serve(input = process.stdin, output = process.stdout) { } catch { return; // ignore malformed frames } - const res = handle(msg); - if (res) output.write(`${JSON.stringify(res)}\n`); + handle(msg).then((res) => { + if (res) output.write(`${JSON.stringify(res)}\n`); + }).catch(() => {}); }); } diff --git a/src/cost_report.js b/src/cost_report.js index 8bf021d..fa0f4b9 100644 --- a/src/cost_report.js +++ b/src/cost_report.js @@ -6,6 +6,9 @@ // or backfilled from a target. The ~90 % figure in the plan stays a TARGET everywhere in this // module's output; the only measured external figure (62 % routing, paper §9) is cited as // context, clearly labeled as not-local. +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { read, record } from "./metrics.js"; import { MODELS } from "./model_tiers.js"; @@ -205,3 +208,57 @@ export function recordGate(root, { halted, ref } = {}) { export function recordRoute(root, { tier, tokensIn, tokensOut, ref } = {}) { return record(root, { stage: "route", tier, tokensIn, tokensOut, ref }); } + +/** + * Fallback spend estimation from Claude's native JSONL session logs when ccusage + * is unavailable. Scans ~/.claude/projects/ for session files and computes cost + * from token counts x model_tiers pricing. Best-effort, never throws. + */ +export function estimateSpendFromLogs() { + try { + const projectsDir = join(homedir(), ".claude", "projects"); + if (!existsSync(projectsDir)) return null; + const pricingPerM = {}; + for (const [, m] of Object.entries(MODELS)) { + pricingPerM[m.id] = { inCost: m.inCost, outCost: m.outCost }; + } + const byModel = {}; + let sessions = 0; + for (const project of readdirSync(projectsDir)) { + const pDir = join(projectsDir, project); + let files; + try { files = readdirSync(pDir); } catch { continue; } + for (const f of files) { + if (!f.endsWith(".jsonl")) continue; + sessions++; + try { + const lines = readFileSync(join(pDir, f), "utf8").split("\n"); + for (const line of lines) { + if (!line.includes('"usage"')) continue; + try { + const entry = JSON.parse(line); + const usage = entry.usage || entry.message?.usage; + const model = entry.model || entry.message?.model || ""; + if (!usage) continue; + const inTok = usage.input_tokens || 0; + const outTok = usage.output_tokens || 0; + if (!byModel[model]) byModel[model] = { inTokens: 0, outTokens: 0 }; + byModel[model].inTokens += inTok; + byModel[model].outTokens += outTok; + } catch {} + } + } catch {} + } + } + let totalCost = 0; + const modelBreakdown = []; + for (const [model, usage] of Object.entries(byModel)) { + const pricing = pricingPerM[model] || { inCost: 3, outCost: 15 }; + const cost = (usage.inTokens * pricing.inCost + usage.outTokens * pricing.outCost) / 1_000_000; + totalCost += cost; + modelBreakdown.push({ model, cost, inTokens: usage.inTokens, outTokens: usage.outTokens }); + } + modelBreakdown.sort((a, b) => b.cost - a.cost); + return { totalCost, sessions, byModel: modelBreakdown }; + } catch { return null; } +} diff --git a/src/dash.html b/src/dash.html index 4d49cac..af7420d 100644 --- a/src/dash.html +++ b/src/dash.html @@ -179,6 +179,7 @@

Author trust — earned from oracle outcomes, floored at 0.50

Cost / Cache — measured, not asserted .forge/metrics.jsonl

+

Recent events

    @@ -278,9 +279,25 @@

    Impact — blast radius .forge/atlas.json

    const stages = Object.entries(d.metrics.stages); const saved = stages.reduce((n, [, s]) => n + (s.savedEstimate || 0), 0); const events = stages.reduce((n, [, s]) => n + (s.events || 0), 0); + const spend = d.spend; $("cost-stats").innerHTML = - '' + saved.toLocaleString() + 'est. tokens saved' + + (spend && spend.totalCost > 0 + ? '$' + spend.totalCost.toFixed(2) + 'real spend' + + '' + spend.sessions + 'sessions' + : '') + + '' + saved.toLocaleString() + 'est. tokens saved' + '' + events + 'stage events'; + if (spend && spend.byModel && spend.byModel.length) { + $("spend-section").innerHTML = '

    Spend by model

    ' + + '' + + spend.byModel.map(function(m) { + return ''; + }).join('') + '
    modelcostin tokout tok
    ' + esc(m.model) + '$' + m.cost.toFixed(4) + + '' + (m.inTokens || 0).toLocaleString() + + '' + (m.outTokens || 0).toLocaleString() + '
    '; + } else { + $("spend-section").innerHTML = ''; + } $("stages").innerHTML = stages.length ? "" + stages.map(([name, s]) => diff --git a/src/dash.js b/src/dash.js index 50ee5dc..cc8d95d 100644 --- a/src/dash.js +++ b/src/dash.js @@ -22,6 +22,7 @@ import { tombstone, } from "./ledger_store.js"; import { read as readMetrics, summarize } from "./metrics.js"; +import { estimateSpendFromLogs } from "./cost_report.js"; import { epochDay, gitAuthor } from "./util.js"; /** A claim is contested when its val sits in this band AND it carries ≥1 oracle @@ -103,7 +104,11 @@ export function dashData(root, { nowDay = epochDay() } = {}) { const a = loadAtlas(root); if (a) atlas = { built: true, symbols: a.symbols?.length ?? 0, files: a.files ?? 0 }; } catch {} - return { repo: basename(root), nowDay, ledger, metrics, atlas }; + let spend = null; + try { + spend = estimateSpendFromLogs(); + } catch {} + return { repo: basename(root), nowDay, ledger, metrics, atlas, spend }; } const HTML_PATH = join(dirname(fileURLToPath(import.meta.url)), "dash.html"); @@ -194,6 +199,10 @@ export function serve(root, { port = 4242, host = "127.0.0.1" } = {}) { return res.end(html); } if (url.pathname === "/api/data") return sendJson(res, 200, dashData(root)); + if (url.pathname === "/api/spend") { + const spend = estimateSpendFromLogs(); + return sendJson(res, 200, spend || { totalCost: 0, sessions: 0, byModel: [] }); + } if (url.pathname === "/api/impact") { const target = url.searchParams.get("target"); if (!target) return sendJson(res, 400, { error: "usage: /api/impact?target=" }); diff --git a/src/init.js b/src/init.js index 511a484..d7c3641 100644 --- a/src/init.js +++ b/src/init.js @@ -1,7 +1,15 @@ // forge init / catalog — the onboarding surface. init gets a repo to a working // state in one command; catalog is the "Start Here" index of everything active. -import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; import { BRAND } from "./brand.js"; import { GITATTRIBUTES_RULE } from "./ledger_store.js"; import { sync } from "./sync.js"; @@ -21,11 +29,123 @@ export function ensureLedgerGitattributes(targetRoot = process.cwd()) { return { written: true }; } +// --------------------------------------------------------------------------- +// Settings merge — auto-install Forge hooks + permissions into the user's +// ~/.claude/settings.json without clobbering existing entries. +// --------------------------------------------------------------------------- + +const FORGE_SETTINGS_MARKER = "forge-managed"; + +function loadTemplate() { + const path = join(BRAND.root, "global", "settings.template.json"); + return JSON.parse(readFileSync(path, "utf8")); +} + +function readJsonSafe(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +/** Deduplicated union of two string arrays. */ +function unionStrings(a = [], b = []) { + const set = new Set(a); + for (const s of b) set.add(s); + return [...set]; +} + +/** Merge Forge hook entries into existing hook arrays, matching by command to avoid duplicates. */ +function mergeHooks(existing = {}, template = {}) { + const merged = { ...existing }; + for (const [event, entries] of Object.entries(template)) { + const existingEntries = merged[event] || []; + const existingCommands = new Set( + existingEntries + .flatMap((e) => (e.hooks || []).map((h) => h.command)) + .filter(Boolean), + ); + const newEntries = []; + for (const entry of entries) { + const hooks = (entry.hooks || []).filter( + (h) => !existingCommands.has(h.command), + ); + if (hooks.length) { + newEntries.push({ ...entry, hooks }); + } + } + merged[event] = [...existingEntries, ...newEntries]; + } + return merged; +} + +/** + * Merge Forge settings (hooks, permissions, statusline) into the user's + * ~/.claude/settings.json. Preserves all existing entries. Idempotent. + * @param {{settingsPath?: string, noSettings?: boolean}} [opts] + */ +export function mergeSettings({ settingsPath, noSettings } = {}) { + if (noSettings) return { action: "skipped", reason: "--no-settings" }; + const target = settingsPath || join(homedir(), ".claude", "settings.json"); + const template = loadTemplate(); + const existing = readJsonSafe(target) || {}; + const report = { added: [], unchanged: [], path: target }; + + // Hooks + if (template.hooks) { + const before = JSON.stringify(existing.hooks || {}); + existing.hooks = mergeHooks(existing.hooks, template.hooks); + if (JSON.stringify(existing.hooks) !== before) report.added.push("hooks"); + else report.unchanged.push("hooks"); + } + + // Permissions + if (template.permissions) { + const ep = existing.permissions || {}; + for (const level of ["allow", "ask", "deny"]) { + if (template.permissions[level]) { + const before = (ep[level] || []).length; + ep[level] = unionStrings(ep[level], template.permissions[level]); + if (ep[level].length > before) report.added.push(`permissions.${level}`); + else report.unchanged.push(`permissions.${level}`); + } + } + if (!ep.defaultMode) ep.defaultMode = template.permissions.defaultMode || "default"; + existing.permissions = ep; + } + + // Statusline — set only if not already configured + if (template.statusLine && !existing.statusLine) { + existing.statusLine = template.statusLine; + report.added.push("statusLine"); + } else if (template.statusLine) { + report.unchanged.push("statusLine"); + } + + // Schema + if (template.$schema && !existing.$schema) existing.$schema = template.$schema; + + // Mark as forge-managed (metadata, won't affect Claude Code) + existing._forge = FORGE_SETTINGS_MARKER; + + // Write back + const dir = dirname(target); + mkdirSync(dir, { recursive: true }); + writeFileSync(target, `${JSON.stringify(existing, null, 2)}\n`); + + return { + action: report.added.length ? "merged" : "unchanged", + ...report, + }; +} + /** Scaffold this repo's cross-tool config (emit every tool) in one step. */ -export function init({ targetRoot = process.cwd() } = {}) { +export function init({ targetRoot = process.cwd(), noSettings = false } = {}) { const r = sync({ targetRoot }); ensureLedgerGitattributes(targetRoot); - return r; + const settings = mergeSettings({ noSettings }); + return { ...r, settings }; } function skillDescription(dir) { diff --git a/src/providers.js b/src/providers.js new file mode 100644 index 0000000..2a4463d --- /dev/null +++ b/src/providers.js @@ -0,0 +1,152 @@ +// forge providers — multi-provider model configuration. Supports Anthropic (default), +// OpenRouter, and custom API endpoints. Config lives in .forge/providers.json; defaults +// are hardcoded so the system works with zero config. Never stores API keys — only env +// var names (the key is resolved at runtime from the environment). +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { MODELS } from "./model_tiers.js"; + +const PROVIDERS_FILE = "providers.json"; +const PROVIDERS_DIR = ".forge"; + +function providersPath(root) { + return join(root, PROVIDERS_DIR, PROVIDERS_FILE); +} + +const BUILTIN_PROVIDERS = { + anthropic: { + type: "anthropic", + label: "Anthropic (direct)", + baseUrl: "https://api.anthropic.com", + envKey: "ANTHROPIC_API_KEY", + models: Object.fromEntries( + Object.entries(MODELS).map(([key, m]) => [key, m.id]), + ), + }, + openrouter: { + type: "openrouter", + label: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + envKey: "OPENROUTER_API_KEY", + models: Object.fromEntries( + Object.entries(MODELS).map(([key, m]) => [key, `anthropic/${m.id}`]), + ), + }, +}; + +function defaults() { + return { + active: "anthropic", + providers: { ...BUILTIN_PROVIDERS }, + }; +} + +/** Load providers config, falling back to built-in defaults. */ +export function loadProviders(root = process.cwd()) { + const path = providersPath(root); + if (!existsSync(path)) return defaults(); + try { + const data = JSON.parse(readFileSync(path, "utf8")); + const merged = defaults(); + if (data.active) merged.active = data.active; + if (data.providers) { + for (const [name, cfg] of Object.entries(data.providers)) { + merged.providers[name] = { ...merged.providers[name], ...cfg }; + } + } + return merged; + } catch { + return defaults(); + } +} + +/** Write providers config. */ +function saveProviders(root, config) { + const dir = join(root, PROVIDERS_DIR); + mkdirSync(dir, { recursive: true }); + writeFileSync(providersPath(root), `${JSON.stringify(config, null, 2)}\n`); +} + +/** The active provider config. */ +export function activeProvider(root = process.cwd()) { + const config = loadProviders(root); + const name = config.active; + const provider = config.providers[name]; + if (!provider) return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic }; + return { name, ...provider }; +} + +/** Resolve a tier key (haiku/sonnet/opus/fable) to the active provider's model ID. */ +export function resolveModel(root, tierKey) { + const provider = activeProvider(root); + return provider.models?.[tierKey] ?? MODELS[tierKey]?.id ?? null; +} + +/** Switch the active provider. Returns the new active config. */ +export function setProvider(root, name) { + const config = loadProviders(root); + if (!config.providers[name]) { + return { ok: false, reason: `unknown provider "${name}" — add it first with forge config provider add` }; + } + config.active = name; + saveProviders(root, config); + return { ok: true, name, provider: config.providers[name] }; +} + +/** + * Register a custom provider. + * @param {string} root + * @param {string} name + * @param {{type?: string, label?: string, baseUrl?: string, envKey?: string, models?: Record}} cfg + */ +export function addProvider(root, name, cfg) { + if (!name || !cfg.baseUrl) { + return { ok: false, reason: "name and --base-url are required" }; + } + const config = loadProviders(root); + config.providers[name] = { + type: cfg.type || "custom", + label: cfg.label || name, + baseUrl: cfg.baseUrl, + envKey: cfg.envKey || "", + models: cfg.models || Object.fromEntries( + Object.entries(MODELS).map(([key, m]) => [key, m.id]), + ), + }; + saveProviders(root, config); + return { ok: true, name, provider: config.providers[name] }; +} + +/** List all configured providers. */ +export function listProviders(root = process.cwd()) { + const config = loadProviders(root); + return Object.entries(config.providers).map(([name, p]) => ({ + name, + active: name === config.active, + type: p.type, + label: p.label || name, + baseUrl: p.baseUrl, + envKey: p.envKey, + hasKey: p.envKey ? Boolean(process.env[p.envKey]) : false, + })); +} + +/** + * Apply a routed model recommendation to ~/.claude/settings.json. + * @param {string} tierKey - the recommended tier (haiku/sonnet/opus/fable) + * @param {{settingsPath?: string}} [opts] + */ +export function applyRoute(tierKey, { settingsPath } = {}) { + const target = settingsPath || join(homedir(), ".claude", "settings.json"); + let settings = {}; + try { + settings = JSON.parse(readFileSync(target, "utf8")); + } catch {} + const modelId = MODELS[tierKey]?.id; + if (!modelId) return { ok: false, reason: `unknown tier "${tierKey}"` }; + const prev = settings.model; + settings.model = tierKey; + writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`); + return { ok: true, model: tierKey, modelId, prev, path: target }; +} diff --git a/test/cortex_mcp.test.js b/test/cortex_mcp.test.js index d58a3db..7b15d5a 100644 --- a/test/cortex_mcp.test.js +++ b/test/cortex_mcp.test.js @@ -8,13 +8,13 @@ import { fileURLToPath } from "node:url"; import { processSession } from "../src/cortex_hook.js"; import { handle } from "../src/cortex_mcp.js"; -test("handle: initialize advertises the forge-cortex server", () => { - const r = handle({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); +test("handle: initialize advertises the forge-cortex server", async () => { + const r = await handle({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); assert.equal(r.result.serverInfo.name, "forge-cortex"); }); -test("handle: tools/list exposes the cortex + preflight tools", () => { - const r = handle({ jsonrpc: "2.0", id: 2, method: "tools/list" }); +test("handle: tools/list exposes the cortex + preflight tools", async () => { + const r = await handle({ jsonrpc: "2.0", id: 2, method: "tools/list" }); const names = r.result.tools.map((t) => t.name); for (const t of [ "cortex_lessons", @@ -22,14 +22,20 @@ test("handle: tools/list exposes the cortex + preflight tools", () => { "preflight_check", "route_task", "scope_files", + "forge_cost", + "forge_dash_data", + "forge_brain", + "forge_ledger_query", + "forge_diagnose", + "forge_doctor", ]) { assert.ok(names.includes(t), `exposes ${t}`); } }); -test("handle: notifications get no response; unknown methods error", () => { - assert.equal(handle({ method: "notifications/initialized" }), null); - assert.equal(handle({ id: 9, method: "bogus" }).error.code, -32601); +test("handle: notifications get no response; unknown methods error", async () => { + assert.equal(await handle({ method: "notifications/initialized" }), null); + assert.equal((await handle({ id: 9, method: "bogus" })).error.code, -32601); }); const SERVER = fileURLToPath(new URL("../src/cortex_mcp.js", import.meta.url)); diff --git a/test/mcp.test.js b/test/mcp.test.js index d4767a0..c220441 100644 --- a/test/mcp.test.js +++ b/test/mcp.test.js @@ -58,12 +58,12 @@ test("MCP merge preserves a user's own server", () => { test("cortex MCP exposes substrate tools", async () => { const { handle } = await import("../src/cortex_mcp.js"); - const listed = handle({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }); + const listed = await handle({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }); const names = listed.result.tools.map((tool) => tool.name); assert.ok(names.includes("substrate_check")); assert.ok(names.includes("predict_impact")); assert.ok(names.includes("assumption_gate")); - const called = handle({ + const called = await handle({ jsonrpc: "2.0", id: 2, method: "tools/call", diff --git a/test/providers.test.js b/test/providers.test.js new file mode 100644 index 0000000..27b814a --- /dev/null +++ b/test/providers.test.js @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + activeProvider, + addProvider, + applyRoute, + listProviders, + loadProviders, + resolveModel, + setProvider, +} from "../src/providers.js"; + +function tmpRoot() { + return mkdtempSync(join(tmpdir(), "forge-prov-")); +} + +test("loadProviders returns defaults when no config file exists", () => { + const root = tmpRoot(); + const config = loadProviders(root); + assert.equal(config.active, "anthropic"); + assert.ok(config.providers.anthropic); + assert.ok(config.providers.openrouter); +}); + +test("activeProvider returns anthropic by default", () => { + const root = tmpRoot(); + const prov = activeProvider(root); + assert.equal(prov.name, "anthropic"); + assert.equal(prov.type, "anthropic"); + assert.ok(prov.models.haiku); +}); + +test("setProvider switches active provider", () => { + const root = tmpRoot(); + const r = setProvider(root, "openrouter"); + assert.ok(r.ok); + assert.equal(r.name, "openrouter"); + const prov = activeProvider(root); + assert.equal(prov.name, "openrouter"); + assert.match(prov.models.haiku, /^anthropic\//); +}); + +test("setProvider rejects unknown provider", () => { + const root = tmpRoot(); + const r = setProvider(root, "nonexistent"); + assert.equal(r.ok, false); + assert.match(r.reason, /unknown provider/); +}); + +test("addProvider registers a custom provider", () => { + const root = tmpRoot(); + const r = addProvider(root, "custom1", { + baseUrl: "https://my-api.example.com", + envKey: "CUSTOM_KEY", + label: "My Custom", + }); + assert.ok(r.ok); + assert.equal(r.provider.baseUrl, "https://my-api.example.com"); + const list = listProviders(root); + assert.ok(list.some((p) => p.name === "custom1")); +}); + +test("addProvider requires baseUrl", () => { + const root = tmpRoot(); + const r = addProvider(root, "bad", {}); + assert.equal(r.ok, false); +}); + +test("resolveModel maps tier to active provider model ID", () => { + const root = tmpRoot(); + const id = resolveModel(root, "haiku"); + assert.ok(id); + assert.ok(!id.includes("/")); + setProvider(root, "openrouter"); + const orId = resolveModel(root, "haiku"); + assert.match(orId, /^anthropic\//); +}); + +test("listProviders shows all with active flag", () => { + const root = tmpRoot(); + const list = listProviders(root); + assert.ok(list.length >= 2); + const active = list.find((p) => p.active); + assert.equal(active.name, "anthropic"); +}); + +test("applyRoute writes model to settings.json", () => { + const root = tmpRoot(); + const settingsDir = join(root, ".claude"); + mkdirSync(settingsDir, { recursive: true }); + const settingsPath = join(settingsDir, "settings.json"); + writeFileSync(settingsPath, "{}"); + const r = applyRoute("sonnet", { settingsPath }); + assert.ok(r.ok); + assert.equal(r.model, "sonnet"); + const written = JSON.parse(readFileSync(settingsPath, "utf8")); + assert.equal(written.model, "sonnet"); +}); + +test("applyRoute rejects unknown tier", () => { + const root = tmpRoot(); + const r = applyRoute("nonexistent", { + settingsPath: join(root, "s.json"), + }); + assert.equal(r.ok, false); + assert.match(r.reason, /unknown tier/); +}); + +test("loadProviders merges user overrides with defaults", () => { + const root = tmpRoot(); + const dir = join(root, ".forge"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "providers.json"), + JSON.stringify({ + active: "openrouter", + providers: { anthropic: { label: "My Anthropic" } }, + }), + ); + const config = loadProviders(root); + assert.equal(config.active, "openrouter"); + assert.equal(config.providers.anthropic.label, "My Anthropic"); + assert.ok(config.providers.anthropic.baseUrl); +});
    stageeventsoutcomessaved