diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index d1f07479..ec24d831 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -18,6 +18,7 @@ const PORCELAIN = { const PLUMBING = { 'daemon-gc': () => import('../src/commands/x/daemon-gc.mjs'), 'mcp': () => import('../src/commands/x/mcp.mjs'), + 'provider': () => import('../src/commands/x/provider.mjs'), 'reference': () => import('../src/commands/x/reference.mjs'), 'verify': () => import('../src/commands/x/verify.mjs'), }; @@ -43,8 +44,9 @@ const HELP_ALL = `${HELP} Plumbing commands: ak x daemon-gc [--kill] list/stop stale ruflo daemons ak x mcp [pick|off|status] MCP registration + tool-family deny rules + ak x provider [pick|off|status] detect claude/codex CLIs; wire ruflo + aqe hosts/providers ak x reference [diff|sync] CLAUDE.md managed-block inspection/reconcile - ak x verify [learning|security|aqe|all] deep proofs (slow, spawns real CLIs) + ak x verify [learning|security|aqe|providers|all] deep proofs (slow, spawns real CLIs) ak x improvement-eval [...] causal self-improvement eval (route Q-learner)`; async function main() { diff --git a/claude/aqe-reference.md b/claude/aqe-reference.md index 75b41f99..a96fecfa 100644 --- a/claude/aqe-reference.md +++ b/claude/aqe-reference.md @@ -40,11 +40,17 @@ called first**, e.g. `fleet_init({ topology:"hierarchical", maxAgents:15, memory `statusLine`, and user `AQE_*` env overrides survive re-init; a one-time `.claude/settings.json.backup` is written first. Keep aqe ≥3.12.1 — 3.11.x init stripped foreign hooks. -- **Run QE on a Claude subscription instead of an API key** (≥3.12.2, runtime env — init - never writes these): `AQE_LLM_PROVIDER=claude-code` routes analysis through `claude -p`; - `AQE_MAX_BUDGET_USD` (or `--max-budget-usd`) enforces a fleet-wide spend cap that aborts - over-budget requests before spending. `aqe health` shows an "LLM Billing" section saying - who pays for each call. +- **Choose which LLM runs QE** (≥3.12.2, runtime env; ADR-123): `AQE_LLM_PROVIDER=` + force-selects the provider for analysis — `claude-code` (your Claude subscription, via + `claude -p`), `claude`/`openai`/`gemini`/`openrouter`/`azure-openai`/`bedrock`/`cognitum` + (metered API key), or `ollama` (local). It normalizes `anthropic`→`claude` and ignores + unknown values. `AQE_MAX_BUDGET_USD` (or `--max-budget-usd`) caps metered spend; `aqe health` + shows an "LLM Billing" section saying who pays. `aqe init` never writes these — but + **`ak x provider pick` now manages `AQE_LLM_PROVIDER` for you** (into + `.claude/settings.local.json` `env`, reversibly), and can write an ordered **fallback chain** + into `.agentic-qe/llm-config.json` from `kit.json` (`--aqe-fallback 'claude-code:claude-opus-4-8; openai:gpt-5.6'`) — + keys stay in the env, never the file. aqe is NOT limited to claude-code: codex the *CLI* isn't a + provider type, but its OpenAI models are reachable via `AQE_LLM_PROVIDER=openai`. ### QE agents via the native Task tool QE agents live under `.claude/agents/v3/` once `aqe init` has run in the repo: diff --git a/claude/providers-reference.md b/claude/providers-reference.md new file mode 100644 index 00000000..24fdb39a --- /dev/null +++ b/claude/providers-reference.md @@ -0,0 +1,83 @@ + + + +## Frontier hosts & LLM providers (claude / codex) + +This machine has **both** frontier-agent CLIs installed. `ak` detects them and wires ruflo + +agentic-qe to use one or both. Two independent axes: + +- **Host axis** — which agent CLI runs the *ruflo* loop: `claude` (Claude Code) and/or `codex` + (OpenAI Codex). ruflo can run **both at once** (dual-mode). +- **Provider axis** — which LLM the *routers* use, independent of the host: + - **agentic-qe** — `AQE_LLM_PROVIDER=` selects any of `claude-code` (subscription), + `claude` / `openai` / `gemini` / `openrouter` / `azure-openai` / `bedrock` / `cognitum` + (metered API key), or `ollama` (local). codex the CLI isn't a provider type, but its OpenAI + models are reachable via `openai`. + - **ruflo** — `anthropic` / `openai` / `google` / `ollama` via `ruflo providers configure`. + - API keys live in the environment; they are never persisted to `kit.json`. + +**One or several — you're never forced to pick just one.** All three surfaces run multiple +providers concurrently: +- **ruflo hosts** — enable `claude` *and* `codex` together (dual-mode); ruflo runs both. +- **ruflo LLM providers** — a list, with load-balancing + automatic failover. +- **agentic-qe** — its `HybridRouter` **auto-enables every provider that has an API key in the + env** and fails over across an ordered chain. `AQE_LLM_PROVIDER` only pins the *default* (the + primary) — the others stay enabled. So `ak x provider` sets aqe's primary; adding + `OPENAI_API_KEY` / `GEMINI_API_KEY` to the env brings those online as fallbacks automatically. + +### aqe fallback chain — managed from `kit.json` + +For **deterministic** ordering (rather than relying on env auto-enable), `ak` writes aqe's +`.agentic-qe/llm-config.json` from `kit.json`: + +```bash +ak x provider pick --aqe-provider claude-code \ + --aqe-fallback 'claude-code:claude-opus-4-8; openai:gpt-5.6; gemini:gemini-3.5-flash' +``` + +Each `provider:model,model` entry becomes an ordered `fallbackChain` entry (first = highest +priority; model IDs are examples current as of July 2026 — use what your provider offers). +ak writes a **complete** chain (aqe merges it shallowly, so partial chains would drop +defaults), sets each provider `enabled`, and tags the file `_managedBy: agentic-kit`. **API keys +are never written** — they stay in the env (aqe refuses to persist them anyway). `ak sync` +reapplies the chain; `ak status` flags drift; `ak x provider off` restores the pre-ak file from +its one-time `.bak` (or removes an ak-created file). Entries need populated models — aqe's router +skips an entry with none. For lower-level edits, `aqe llm-router config` still works. + +### Managing it (prompts-once, reversible) + +```bash +ak x provider status # detected CLIs + versions, what's enabled, what's wired +ak x provider pick # choose ruflo hosts / aqe provider / ruflo API providers → persist → apply +ak x provider off # reset to claude-only default; strip managed env keys +``` + +`pick` persists your choice to `kit.json` and applies it: it writes the ruflo backend flags +(`ENABLE_CLAUDE_CODE` / `ENABLE_CODEX`) and `AQE_LLM_PROVIDER` into +`.claude/settings.local.json` `env` (merge-not-clobber, backup-first), runs +`ruflo init --dual` when codex is enabled, and registers any API-key providers with ruflo. +`ak sync` reapplies the same choice idempotently; `ak status` shows **hosts** and +**providers** rows and flags drift. At the claude-only default nothing is written — behavior +is unchanged until you opt in. + +### Install & update (install-method-aware) + +- **First install** — `ak setup` (and `ak x provider pick`) installs any *enabled* host that + is entirely **absent**: `npm i -g @anthropic-ai/claude-code` / `@openai/codex`. +- **Updates** — `ak sync` keeps **npm-managed** hosts current (drift is detected on the same + cached TTL as ruflo/aqe, and surfaces in the bin nudge + `ak status`). +- **Externally-installed CLIs are never touched.** If a host was installed by mise, the + native installer, or Homebrew, ak reports its version and marks it *self-managed* — it will + not shadow it with an npm copy or try to update it. Update those with your own tool. + +### Grounding (rUv source) + +- ruflo **ADR-034 Optional MCP Backends** (accepted): Claude Code / Gemini / OpenAI Codex + backends enabled via `ENABLE_CLAUDE_CODE` / `ENABLE_GEMINI_MCP` / `ENABLE_CODEX`. +- `@claude-flow/codex` adapter + `ruflo init --dual` ("Initialize for both Claude Code and + OpenAI Codex"). +- `ruflo providers list|configure|test` — the API-key provider matrix. + + diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 00000000..9c5d38fe --- /dev/null +++ b/docs/PROVIDERS.md @@ -0,0 +1,116 @@ +# Model providers & hosts — the simple path, and how to go deeper + +`ak`'s job is to make **the best default the simplest thing** — and then get out of your +way when you want to customize, exactly as you would if you drove `ruflo` and `agentic-qe` +by hand. Everything `ak` writes is *their* standard config; `ak` just converges to it, +proves it, and can undo it. + +There are two independent things you can point at a model: + +- **Hosts** — which agent CLI runs the *ruflo* loop: `claude` (Claude Code), `codex` (OpenAI + Codex), or **both** at once. +- **Providers** — which LLM the *routers* use: ruflo's provider router and agentic-qe's + `HybridRouter`. Independent of the host; API keys always live in your environment. + +--- + +## Level 0 — do nothing (the point) + +Install `ak`, run `ak setup`. Claude Code is the host, agentic-qe uses its own default, and +nothing about providers is written anywhere. This is the whole feature for most people: +**it already works, and `kit.json` stays at its defaults.** + +``` +ak setup # claude just works; codex/other providers are opt-in +ak status # shows a "hosts" + "providers" row so you can see what's true +``` + +If you happen to have `codex` installed, `ak` notices and *offers* — it never flips it on +for you: + +``` +ℹ codex CLI detected — run `ak x provider pick` to let ruflo use both claude and codex +``` + +## Level 1 — turn on codex (one command) + +``` +ak x provider pick +``` + +An interactive picker (or flags for scripts). Enable `codex` and `ak`: +- installs it if it's missing (`npm i -g @openai/codex`) — but leaves an existing + mise/brew/native install alone, +- runs `ruflo init --dual` (ruflo's "Claude Code + Codex hybrid" mode), +- writes `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` into `.claude/settings.local.json`. + +``` +ak x provider pick --host claude,codex --yes # non-interactive +``` + +## Level 2 — choose which LLM runs QE + +agentic-qe can run its analysis on any of: `claude-code` (your Claude subscription), +`claude` / `openai` / `gemini` / `openrouter` / `azure-openai` / `bedrock` / `cognitum` +(metered API key), or `ollama` (local). + +``` +ak x provider pick --aqe-provider claude-code # run QE on your subscription, no API bill +``` + +`ak` writes `AQE_LLM_PROVIDER` for you. Add `OPENAI_API_KEY` to your env and agentic-qe's +router will **auto-enable** OpenAI as a fallback on its own — you don't have to list it. + +## Level 3 — a deterministic fallback chain + +When you want explicit ordering rather than env auto-enable, `ak` manages agentic-qe's +`.agentic-qe/llm-config.json` from `kit.json`: + +``` +ak x provider pick \ + --aqe-provider claude-code \ + --aqe-fallback 'claude-code:claude-opus-4-8; openai:gpt-5.6; gemini:gemini-3.5-flash' +``` + +Each `provider:model,model` becomes an ordered chain entry (first = highest priority). `ak` +writes a complete, schema-correct chain, tags it `_managedBy: agentic-kit`, and **never** +writes your API keys. + +> Model IDs above are examples current as of July 2026 (Claude Opus 4.8, OpenAI GPT-5.6 — +> or `gpt-5.3-codex` for agentic coding — Google Gemini 3.5 Flash). Use whatever IDs your +> provider currently offers; `ak` writes the strings you give it verbatim. + +## Level 4 — drop down to raw ruflo / agentic-qe + +This is the part that matters: **`ak` is a facilitator, not a wall.** Every value it manages +is the tool's own native config, and you can set it by hand — or let `ak` and hand-edits +coexist. `ak` merges-not-clobbers and backs up first, mirroring how rUv itself layers config +(`mergeWithDefaults(config, defaults)` — sensible defaults, override with your partial). + +| You want to… | `ak` way | The raw ruflo/aqe way it maps to | +| ------------------------------------ | --------------------------------- | --------------------------------------------------- | +| Enable claude/codex hosts | `ak x provider pick` | `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` env (ADR-034) + `ruflo init --dual` | +| Register a ruflo LLM provider | `--provider openai:gpt-5.6` | `ruflo providers configure -p openai -m gpt-5.6` | +| Set which LLM runs QE | `--aqe-provider gemini` | `AQE_LLM_PROVIDER=gemini` (env) | +| Order QE's fallback chain | `--aqe-fallback '…'` | edit `.agentic-qe/llm-config.json` / `aqe llm-router config` | +| Cap QE spend | (kit.json `maxBudgetUsd`) | `AQE_MAX_BUDGET_USD` / `--max-budget-usd` | + +If you hand-edit `.agentic-qe/llm-config.json` yourself and *don't* use `ak`'s +`--aqe-fallback`, `ak` leaves your file alone — it only manages a chain it owns (the +`_managedBy` tag). Keys always stay in the environment; neither `ak` nor aqe persists them. + +## Undo, always + +``` +ak x provider off # reset to the claude-only default, reversibly +``` + +Strips the managed env keys (leaving your other settings), and restores your pre-`ak` +`llm-config.json` from its one-time backup — or removes the file if `ak` created it. `ak +status` and `ak sync` keep everything converged and flag drift in between. + +--- + +**The shape of the whole thing:** Level 0 is the 90% case and costs nothing. Each level up is +one flag, and the bottom is always the tools' own knobs — `ak` never traps your config, it +just makes the good default automatic and the customization reversible. diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index ff705960..97cbc84e 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -13,6 +13,7 @@ import { fixStatusline } from '../lib/statusline.mjs'; import { registry, syncBlocks } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; +import { HOSTS, applyHosts, applyProviders, ensureDualAgents, hostInstallState, installHost, applyAqeRouter } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; import { readJson, writeJsonWithBackup } from '../lib/settings.mjs'; import { scalar, checkpoint, withDb } from '../lib/sqlite.mjs'; @@ -86,6 +87,26 @@ export async function run_machine({ flags, pkgRoot, cfg }) { ok(`MCP registered${denied ? ` (${denied} tool(s) denied per kit.json)` : ''} — exclude families anytime: ak x mcp pick`); } else warn('claude mcp add failed — run: ak x mcp pick'); } + + // 6. frontier hosts — install any ENABLED host that is entirely absent (default + // enables claude only). External installs (mise/native/brew) are left alone. + for (const h of HOSTS) { + if (!cfg.providers?.hosts?.[h.id]) continue; + const st = await hostInstallState(h); + if (st.method === 'absent') { + if (await ask(`${h.id} CLI not found — install ${h.pkg} globally?`, true, flags.yes)) { + const r = await installHost(h.id); + (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); + } else warn(`${h.id} not installed — enable/install later with: ak x provider pick`); + } else { + ok(`${h.id} ${st.version ?? ''} present (${st.method}${st.method === 'external' ? ' — self-managed' : ''})`); + } + } + + // 7. frontier host hint — codex detected but not enabled (opt-in via `x provider pick`) + if (!cfg.providers?.hosts?.codex && await have('codex')) { + info('codex CLI detected — run `ak x provider pick` to let ruflo use both claude and codex'); + } return true; } @@ -174,6 +195,22 @@ export async function run_project({ flags, cfg }) { (aqe.code === 0 ? ok : warn)('agentic-qe initialized'); } + // 9.5 frontier host/provider wiring — reapply kit.json prefs (no-op at the + // claude-only default, so existing repos see zero change). When codex is + // enabled: write ENABLE_* env, regenerate dual-mode agents, register providers. + const ph = applyHosts(cfg, root); + if (ph.changed) ok(`providers: ${ph.detail}`); + const rt = applyAqeRouter(cfg, root); + if (rt.changed) (rt.ok ? ok : warn)(`aqe router: ${rt.detail}`); + if (cfg.providers?.hosts?.codex) { + const dual = await ensureDualAgents(cfg, root); + (dual.ok ? ok : warn)(`dual agents: ${dual.detail}`); + const prov = await applyProviders(cfg, root); + if (prov.changed) (prov.ok ? ok : warn)(`providers: ${prov.detail}`); + } else if (await have('codex')) { + info('codex CLI detected — enable dual-host with: ak x provider pick'); + } + // 10. statusline footer — LAST, after ruflo + aqe have settled the helper. // A still-missing footer is a WARN (not silent info): it means the AQE / // SONA segments won't render and `ak sync` is needed to heal it. diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 718d3e52..f6d076ba 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -13,6 +13,8 @@ import { registry, syncBlocks } from '../lib/blocks.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import { readJson } from '../lib/settings.mjs'; +import { have } from '../lib/exec.mjs'; +import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, aqeRouterFile } from '../lib/providers.mjs'; export const options = { json: { type: 'boolean', default: false }, @@ -130,6 +132,58 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('mcp', 'warn', "legacy 'ruflo'-keyed MCP registration present", 'sync migrates it to claude-flow')); } + // hosts (install-if-missing) — cheap: file read + `which`, no network. + // An enabled host that is entirely absent is installable by sync; an external + // install (mise/native/brew) is reported but never touched. + try { + for (const h of HOSTS) { + if (!cfg.providers.hosts[h.id]) continue; + const st = await hostInstallState(h); + if (st.method === 'absent') { + rows.push(row('hosts', h.id === 'claude' ? 'fail' : 'warn', + `${h.id} enabled but not installed`, `sync installs ${h.pkg}`)); + } else { + rows.push(row('hosts', 'ok', `${h.id} ${st.version ?? ''} (${st.method}${st.method === 'external' ? ' — self-managed' : ''})`)); + } + } + } catch (e) { + rows.push(row('hosts', 'warn', `host check unavailable: ${e.message}`)); + } + + // providers (frontier host wiring) — light: `have` probe + env read, no --version + try { + const { file, scope } = settingsTarget(cwd); + const env = readJson(file, {})?.env ?? {}; + if (isDefault(cfg)) { + // advisory only (no fix): opting codex in is a deliberate `x provider pick` + if (await have('codex')) { + rows.push(row('providers', 'info', 'codex CLI installed but not enabled (claude-only default)')); + } else { + rows.push(row('providers', 'info', 'claude-only (default host)')); + } + } else { + const desired = managedEnv(cfg); + const envDrift = MANAGED_ENV_KEYS.some((k) => (k in desired ? env[k] !== desired[k] : k in env)); + // aqe fallback chain: on-disk llm-config.json must match kit.json order + const chain = cfg.providers.aqeFallback ?? []; + let routerDrift = false; + if (chain.length) { + const disk = readJson(aqeRouterFile(cwd)); + const diskOrder = (disk?.fallbackChain?.entries ?? []).map((e) => e.provider).join('→'); + routerDrift = disk?._managedBy !== 'agentic-kit' || diskOrder !== chain.map((e) => e.provider).join('→'); + } + const on = HOSTS.filter((h) => cfg.providers.hosts[h.id]).map((h) => h.id).join('+') || 'none'; + const chainStr = chain.length ? `; aqe chain ${chain.map((e) => e.provider).join('→')}` : ''; + if (envDrift || routerDrift) { + rows.push(row('providers', 'warn', `provider config drifted (want ${on}${chainStr}, ${scope})`, 'sync re-applies provider env + aqe router')); + } else { + rows.push(row('providers', 'ok', `wired: ${on}${chainStr} (${scope})`)); + } + } + } catch (e) { + rows.push(row('providers', 'warn', `provider check unavailable: ${e.message}`)); + } + // daemons try { const daemons = await listDaemons({ cwd }); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 7f9218d9..c3ea6d71 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -9,6 +9,7 @@ import { registry, syncBlocks } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig } from '../lib/config.mjs'; +import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter } from '../lib/providers.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, bold, dim } from '../lib/output.mjs'; @@ -73,6 +74,22 @@ export async function run({ flags, pkgRoot }) { const res = await syncBlocks(paths.claudeMdPath(), rowsReg, resolve); ok(`blocks: ${res.filter((r) => r.action !== 'unchanged').map((r) => `${r.slug} ${r.action}`).join(', ') || 'in sync'}`); } + // hosts: install any ENABLED host that is entirely absent (updates to + // npm-managed hosts ride the versions branch above via driftReport). + if (subsystems.has('hosts')) { + for (const h of HOSTS) { + if (!cfg.providers.hosts[h.id]) continue; + if ((await hostInstallState(h)).method !== 'absent') continue; + report(`install ${h.id}`, await installHost(h.id)); + } + } + if (subsystems.has('providers')) { + report('providers', applyHosts(cfg, cwd)); + const router = applyAqeRouter(cfg, cwd); + if (router.changed || !router.ok) report('aqe router', router); + const prov = await applyProviders(cfg, cwd); + if (prov.changed || !prov.ok) report('providers (api)', prov); + } if (subsystems.has('statusline') || subsystems.has('versions')) { const r = fixStatusline(cwd); (r.applied || !r.reason ? ok : warn)(`statusline: ${r.applied ? `footer injected (v${r.version})` : r.reason ?? 'in sync'}`); diff --git a/src/commands/x/provider.mjs b/src/commands/x/provider.mjs new file mode 100644 index 00000000..e3c93f81 --- /dev/null +++ b/src/commands/x/provider.mjs @@ -0,0 +1,198 @@ +// x provider — frontier-host + LLM-provider detection and wiring. +// status (default) : detected CLIs, aqe provider, ruflo providers, what's wired +// pick : choose enabled hosts / aqe provider / ruflo providers → persist → apply +// off : reversible teardown (strip managed env keys) +// Mirrors `ak x mcp`: detect → persist to kit.json → idempotent heal. +// Two independent axes: ruflo host CLIs (claude/codex) and the LLM the routers use. +import readline from 'node:readline/promises'; +import { + HOSTS, API_PROVIDERS, AQE_PROVIDER_TYPES, detectHosts, detectProviders, + settingsTarget, isDefault, applyHosts, applyProviders, ensureDualAgents, + undoProviders, hostInstallState, installHost, applyAqeRouter, undoAqeRouter, +} from '../../lib/providers.mjs'; +import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; +import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs'; + +export const options = { + host: { type: 'string' }, // csv: claude,codex (pick, non-interactive) + 'aqe-provider': { type: 'string' }, // one of AQE_PROVIDER_TYPES, or 'none' to unset + 'aqe-fallback': { type: 'string' }, // 'claude-code:model1,model2;openai:gpt-5.6' ('none' clears) + provider: { type: 'string' }, // csv of ruflo API providers, optional id:model (openai:gpt-5.6) + yes: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, +}; + +/** Parse 'claude-code:m1,m2; openai:gpt-5.6' → [{provider, models:[…]}, …]. */ +const parseFallback = (str) => str.split(';').map((s) => s.trim()).filter(Boolean).map((tok) => { + const [provider, models] = tok.split(':'); + return { provider: provider.trim().toLowerCase(), models: (models ?? '').split(',').map((m) => m.trim()).filter(Boolean) }; +}); + +export async function run({ flags, positionals }) { + const sub = positionals[0] ?? 'status'; + const cwd = process.cwd(); + + if (sub === 'status') return status({ flags, cwd }); + if (sub === 'off') return off({ cwd }); + if (sub === 'pick') return pick({ flags, cwd }); + + fail(`unknown provider subcommand: ${sub} (status|pick|off)`); + return 2; +} + +async function status({ flags, cwd }) { + const cfg = loadKitConfig(); + const hosts = await detectHosts(cwd); + const providers = detectProviders(); + const { scope } = settingsTarget(cwd); + + if (flags.json) { + console.log(JSON.stringify({ scope, config: cfg.providers, hosts, providers }, null, 2)); + return 0; + } + + const dflt = isDefault(cfg); + console.log(bold('ruflo agent hosts') + dim(` (wiring scope: ${scope})`)); + for (const h of HOSTS) { + const d = hosts[h.id]; + const enabled = !!cfg.providers.hosts[h.id]; + const state = !d.present ? dim('not installed') + : !enabled ? 'installed, disabled' + : dflt ? 'enabled (default — ruflo default-on, no env written)' + : d.wired ? 'enabled, wired' + : 'enabled, not wired → ak sync'; + console.log(` ${h.id.padEnd(7)} ${(d.version ? `v${d.version}` : '—').padEnd(12)} ${state}`); + } + + // agentic-qe LLM provider (AQE_LLM_PROVIDER) + fallback chain + const ap = cfg.providers.aqeProvider; + console.log(bold('\nagentic-qe LLM provider') + dim(' (AQE_LLM_PROVIDER)')); + console.log(` ${(ap ?? dim('aqe default (unset)')).padEnd(24)} ${dim(`supported: ${AQE_PROVIDER_TYPES.join(', ')}`)}`); + const chain = cfg.providers.aqeFallback ?? []; + if (chain.length) { + const rendered = chain.map((e) => `${e.provider}${e.models?.length ? `(${e.models.join(',')})` : dim('(no models)')}`).join(' → '); + console.log(` ${dim('fallback chain:')} ${rendered} ${dim('· .agentic-qe/llm-config.json')}`); + } else { + console.log(` ${dim('fallback chain: none (aqe auto-enables keyed providers)')}`); + } + + const cm = cfg.providers.models ?? []; + console.log(bold('\nruflo LLM API providers') + dim(' (ruflo router; keys read from env)')); + for (const p of API_PROVIDERS) { + const cfgEntry = cm.find((m) => m.id === p.id); + const key = p.keyEnv.length ? (providers[p.id].keyPresent ? 'key present' : 'no key') : 'local'; + const conf = cfgEntry ? `configured${cfgEntry.model ? ` (${cfgEntry.model})` : ''}` : dim('not configured'); + console.log(` ${p.id.padEnd(10)} ${key.padEnd(12)} ${conf}`); + } + + const codexIdle = hosts.codex.present && !cfg.providers.hosts.codex; + console.log(''); + if (codexIdle) info('codex is installed but disabled — enable it with: ak x provider pick'); + else ok('provider config reflects installed CLIs'); + return 0; +} + +async function off({ cwd }) { + const cfg = loadKitConfig(); + cfg.providers = { hosts: { claude: true, codex: false }, aqeProvider: null, aqeFallback: [], models: [], maxBudgetUsd: null }; + saveKitConfig(cfg); + const env = undoProviders(cwd); + const router = undoAqeRouter(cwd); + ok(`reset to claude-only default — ${env.detail}; ${router.detail}`); + return 0; +} + +const parseModels = (csv) => csv.split(',').map((s) => s.trim()).filter(Boolean).map((tok) => { + const [id, model] = tok.split(':'); + return model ? { id, model } : { id }; +}); + +async function pick({ flags, cwd }) { + const cfg = loadKitConfig(); + const hosts = await detectHosts(cwd); + let enabled; + let aqeProvider = cfg.providers.aqeProvider ?? null; + let aqeFallback = cfg.providers.aqeFallback ?? []; + let models = cfg.providers.models ?? []; + + const nonInteractive = flags.host !== undefined || flags['aqe-provider'] !== undefined + || flags['aqe-fallback'] !== undefined || flags.provider !== undefined; + if (nonInteractive) { + enabled = flags.host !== undefined + ? flags.host.split(',').map((s) => s.trim()).filter(Boolean) + : Object.entries(cfg.providers.hosts).filter(([, v]) => v).map(([k]) => k); + if (flags['aqe-provider'] !== undefined) { + const v = flags['aqe-provider'].trim().toLowerCase(); + aqeProvider = (v === 'none' || v === '') ? null : v; + } + if (flags['aqe-fallback'] !== undefined) { + const v = flags['aqe-fallback'].trim().toLowerCase(); + aqeFallback = (v === 'none' || v === '') ? [] : parseFallback(v); + } + if (flags.provider !== undefined) models = parseModels(flags.provider); + } else { + const installed = HOSTS.filter((h) => hosts[h.id].present).map((h) => h.id); + if (installed.length === 0) { fail('no frontier CLI (claude/codex) found on PATH'); return 1; } + console.log(`Installed hosts: ${installed.join(', ')}`); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const hAns = (await rl.question(`Enable which ruflo host(s)? (comma-separated) [${installed.join(',')}]: `)).trim(); + enabled = (hAns || installed.join(',')).split(',').map((s) => s.trim()).filter(Boolean); + const aAns = (await rl.question(`agentic-qe primary LLM provider — ${AQE_PROVIDER_TYPES.join('/')} (blank = leave aqe default): `)).trim().toLowerCase(); + aqeProvider = aAns ? aAns : null; + const fAns = (await rl.question('aqe fallback chain, ordered (e.g. "claude-code:claude-opus-4-8; openai:gpt-5.6", blank = none): ')).trim().toLowerCase(); + aqeFallback = fAns ? parseFallback(fAns) : []; + const provAns = (await rl.question('ruflo API-key providers to register (e.g. openai:gpt-5.6, blank to skip): ')).trim(); + if (provAns) models = parseModels(provAns); + rl.close(); + } + + // validate hosts + const known = new Set(HOSTS.map((h) => h.id)); + enabled = enabled.filter((h) => known.has(h)); + if (!enabled.includes('claude') && !enabled.includes('codex')) enabled = ['claude']; + // validate aqe primary provider + if (aqeProvider && !AQE_PROVIDER_TYPES.includes(aqeProvider)) { + const norm = aqeProvider === 'anthropic' ? 'claude' : aqeProvider; + if (AQE_PROVIDER_TYPES.includes(norm)) aqeProvider = norm; + else { warn(`unknown aqe provider '${aqeProvider}' — leaving aqe on its default (valid: ${AQE_PROVIDER_TYPES.join(', ')})`); aqeProvider = null; } + } + // validate fallback chain providers + aqeFallback = aqeFallback + .map((e) => ({ ...e, provider: e.provider === 'anthropic' ? 'claude' : e.provider })) + .filter((e) => { + const okp = AQE_PROVIDER_TYPES.includes(e.provider); + if (!okp) warn(`dropping unknown fallback provider '${e.provider}'`); + else if (!e.models.length) warn(`fallback entry '${e.provider}' has no models — aqe may skip it; add e.g. ${e.provider}:`); + return okp; + }); + + cfg.providers = { + hosts: { claude: enabled.includes('claude'), codex: enabled.includes('codex') }, + aqeProvider, + aqeFallback, + models, + maxBudgetUsd: cfg.providers.maxBudgetUsd ?? null, + }; + saveKitConfig(cfg); + + // install any enabled host that is entirely absent (external installs untouched) + for (const h of HOSTS) { + if (!cfg.providers.hosts[h.id]) continue; + if ((await hostInstallState(h)).method !== 'absent') continue; + info(`${h.id} not installed — installing ${h.pkg}…`); + const r = await installHost(h.id); + (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); + } + + const h = applyHosts(cfg, cwd); + (h.ok ? ok : fail)(`hosts: ${h.detail}`); + if (aqeProvider) ok(`aqe provider: AQE_LLM_PROVIDER=${aqeProvider}`); + const router = applyAqeRouter(cfg, cwd); + if (router.changed || !router.ok) (router.ok ? ok : warn)(`aqe router: ${router.detail}`); + const dual = await ensureDualAgents(cfg, cwd); + (dual.ok ? (dual.changed ? ok : info) : warn)(`dual agents: ${dual.detail}`); + const prov = await applyProviders(cfg, cwd); + (prov.ok ? (prov.changed ? ok : info) : warn)(`ruflo providers: ${prov.detail}`); + ok('saved to kit.json — reapplied on every `ak sync`; undo with `ak x provider off`'); + return 0; +} diff --git a/src/commands/x/verify.mjs b/src/commands/x/verify.mjs index b3963fc9..26fcb1e3 100644 --- a/src/commands/x/verify.mjs +++ b/src/commands/x/verify.mjs @@ -4,10 +4,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { run as runCmd } from '../../lib/exec.mjs'; +import { run as runCmd, have } from '../../lib/exec.mjs'; import { aidefencePresent, securityPresent } from '../../lib/natives.mjs'; import { scanRvf } from '../../lib/rvf.mjs'; import { projectAqeDir } from '../../lib/paths.mjs'; +import { loadKitConfig } from '../../lib/config.mjs'; +import { HOSTS, detectHosts, aqeRouterFile } from '../../lib/providers.mjs'; +import { readJson } from '../../lib/settings.mjs'; import { ok, warn, fail, heading } from '../../lib/output.mjs'; export const options = { json: { type: 'boolean', default: false } }; @@ -57,11 +60,45 @@ async function verifyAqe() { return true; } +async function verifyProviders() { + heading('providers — kit config matches installed CLIs; ruflo/aqe see the wiring'); + const cfg = loadKitConfig(); + let good = true; + // enabled hosts must actually be installed + const hosts = await detectHosts(process.cwd()); + for (const h of HOSTS) { + if (!cfg.providers?.hosts?.[h.id]) continue; + if (hosts[h.id].present) ok(`host '${h.id}' enabled and installed${hosts[h.id].version ? ` (v${hosts[h.id].version})` : ''}`); + else { fail(`host '${h.id}' enabled in kit.json but not on PATH`); good = false; } + } + // ruflo sees its provider list + if (await have('ruflo')) { + const list = await runCmd('ruflo', ['providers', 'list'], { timeout: 60_000 }); + (list.code === 0 ? ok : warn)(`ruflo providers list ${list.code === 0 ? 'ok' : 'unavailable'}`); + } + // aqe billing section reflects the host selector + if (cfg.aqe !== false && await have('aqe')) { + const h = await runCmd('aqe', ['health'], { timeout: 120_000 }); + const seen = /LLM Billing|claude-code|provider|billing/i.test(h.stdout + h.stderr); + (seen ? ok : warn)('aqe health reports an LLM billing/provider section'); + } + // aqe fallback chain: on-disk llm-config.json matches kit.json (order + ak-managed) + const chain = cfg.providers?.aqeFallback ?? []; + if (chain.length) { + const disk = readJson(aqeRouterFile(process.cwd())); + const diskOrder = (disk?.fallbackChain?.entries ?? []).map((e) => e.provider).join(' → '); + const want = chain.map((e) => e.provider).join(' → '); + if (disk?._managedBy === 'agentic-kit' && diskOrder === want) ok(`aqe fallback chain on disk matches kit.json (${want})`); + else { fail(`aqe fallback chain drift — disk="${diskOrder}" want="${want}" (run: ak sync)`); good = false; } + } + return good; +} + export async function run({ positionals }) { const which = positionals[0] ?? 'all'; - const suites = { learning: verifyLearning, security: verifySecurity, aqe: verifyAqe }; + const suites = { learning: verifyLearning, security: verifySecurity, aqe: verifyAqe, providers: verifyProviders }; const selected = which === 'all' ? Object.entries(suites) : [[which, suites[which]]]; - if (!selected.every(([, fn]) => fn)) { fail(`unknown suite: ${which} (learning|security|aqe|all)`); return 2; } + if (!selected.every(([, fn]) => fn)) { fail(`unknown suite: ${which} (learning|security|aqe|providers|all)`); return 2; } let allGood = true; for (const [, fn] of selected) allGood = (await fn()) && allGood; console.log(''); diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index e116c1fe..cdf34da6 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -41,6 +41,14 @@ export const BUILTIN_BLOCKS = [ // shell impl: find ~/.claude/plugins/cache -maxdepth 4 -type d -name superpowers detector: { type: 'glob-dir', target: 'superpowers', root: 'plugins/cache', maxDepth: 4 }, }, + { + // Only surfaces once the codex CLI is on PATH — mirrors the aqe block gated on + // `command: aqe`. Documents the claude/codex host axis + `ak x provider`. + slug: 'ruflo-providers-reference', + template: 'providers-reference.md', + position: 'append', + detector: { type: 'command', target: 'codex' }, + }, ]; /** Evaluate a declarative detector. Returns boolean. */ diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 66fe2963..6380e85f 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -9,6 +9,16 @@ const DEFAULTS = { aqe: true, // manage agentic-qe alongside ruflo security: true, // run the security verification surface by default mcp: { register: true, excludeFamilies: [] }, + // Frontier hosts + LLM providers (prompts-once via `ak x provider pick`). + // Default = claude-only, codex opt-in — preserves today's behavior exactly: + // when this stays at defaults, the provider heal is a deliberate no-op. + providers: { + hosts: { claude: true, codex: false }, // which agent CLIs ruflo may run (ADR-034 ENABLE_*) + aqeProvider: null, // AQE_LLM_PROVIDER (claude-code|openai|gemini|…); null = aqe default + aqeFallback: [], // [{ provider, models:[...] }] — ordered aqe fallback chain (.agentic-qe/llm-config.json) + models: [], // [{ id:'openai', model:'gpt-5.6' }] — ruflo API-key providers + maxBudgetUsd: null, // → AQE_MAX_BUDGET_USD when set + }, customBlocks: [], // [{slug, templatePath, detector:{type:'command'|'dir'|'file', target}}] versionCheck: { ttlHours: 24, last: null, seen: {} }, }; @@ -19,7 +29,16 @@ export function loadKitConfig(file = kitConfigPath()) { for (const cand of file === kitConfigPath() ? [file, legacyKitConfigPath()] : [file]) { try { const parsed = JSON.parse(fs.readFileSync(cand, 'utf8')); - return { ...structuredClone(DEFAULTS), ...parsed, mcp: { ...DEFAULTS.mcp, ...parsed.mcp } }; + return { + ...structuredClone(DEFAULTS), + ...parsed, + mcp: { ...DEFAULTS.mcp, ...parsed.mcp }, + providers: { + ...DEFAULTS.providers, + ...parsed.providers, + hosts: { ...DEFAULTS.providers.hosts, ...parsed.providers?.hosts }, + }, + }; } catch { /* try next */ } } return structuredClone(DEFAULTS); diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs new file mode 100644 index 00000000..6ea1c663 --- /dev/null +++ b/src/lib/providers.mjs @@ -0,0 +1,317 @@ +// Frontier-host + LLM-provider detection and wiring. +// +// why: rUv ships this downstream — ak only detects + wires it (detect→heal→verify), +// it does NOT reimplement provider machinery. Grounded in rUv source: +// - ruflo ADR-034 "Optional MCP Backends" (ACCEPTED): Claude Code / Gemini / OpenAI +// Codex backends are enabled via env vars ENABLE_CLAUDE_CODE / ENABLE_CODEX / +// ENABLE_GEMINI_MCP. +// - @claude-flow/codex adapter (bin claude-flow-codex); `ruflo init --dual` = +// "Initialize for both Claude Code and OpenAI Codex". +// - `ruflo providers configure -p -m ` persists API-key providers +// (anthropic/openai/google/ollama) to ruflo's config. +// - agentic-qe LLM selector `AQE_LLM_PROVIDER=` (ADR-123, +// dist/shared/llm/router/config-store.js) force-selects ANY provider in +// ALL_PROVIDER_TYPES — claude-code (subscription), claude/openai/gemini/ +// openrouter/azure-openai/bedrock/cognitum (metered api), ollama (local). It +// normalizes `anthropic`→`claude` and warns on unknown values. So aqe is NOT +// limited to claude-code; codex-the-CLI simply isn't a provider *type* (its +// OpenAI models are reached via `openai`). +// +// Two independent axes: +// host axis — which agent CLI runs the ruflo loop (claude, codex). ruflo runs +// both at once (dual-mode). This is about the coding-agent CLI. +// provider axis — which LLM the *routers* use: ruflo's API-key providers +// (`ruflo providers configure`) and aqe's `AQE_LLM_PROVIDER`. +// Independent of the host axis; keys live in the env, never kit.json. +import fs from 'node:fs'; +import path from 'node:path'; +import { run, have } from './exec.mjs'; +import { readJson, writeJsonWithBackup } from './settings.mjs'; +import { installedVersion, cmpVersions } from './versions.mjs'; +import * as paths from './paths.mjs'; + +/** Frontier agent-CLI hosts. `pkg` is the npm global package; `enableEnv` is + * ruflo's ADR-034 backend flag; `aqe` is the AQE_LLM_PROVIDER value (null when + * aqe can't host it). */ +export const HOSTS = [ + { id: 'claude', bin: 'claude', pkg: '@anthropic-ai/claude-code', enableEnv: 'ENABLE_CLAUDE_CODE', aqe: 'claude-code' }, + { id: 'codex', bin: 'codex', pkg: '@openai/codex', enableEnv: 'ENABLE_CODEX', aqe: null }, +]; + +/** API-key LLM providers ruflo's router understands (`ruflo providers`). */ +export const API_PROVIDERS = [ + { id: 'anthropic', keyEnv: ['ANTHROPIC_API_KEY'] }, + { id: 'openai', keyEnv: ['OPENAI_API_KEY'] }, + { id: 'google', keyEnv: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] }, + { id: 'ollama', keyEnv: [] }, // local; presence = reachable daemon (not checked here) +]; + +/** Valid `AQE_LLM_PROVIDER` values (grounded: aqe billing-modes + router types). + * aqe force-selects any of these for its QE analysis, independent of ruflo's + * host. `claude-code` = Claude subscription; ollama = local; the rest metered. */ +export const AQE_PROVIDER_TYPES = [ + 'claude-code', 'claude', 'openai', 'gemini', 'openrouter', + 'azure-openai', 'bedrock', 'cognitum', 'ollama', +]; + +/** Every env key this module owns — the reversible surface for `off`/undo. */ +export const MANAGED_ENV_KEYS = [ + 'ENABLE_CLAUDE_CODE', 'ENABLE_CODEX', 'ENABLE_GEMINI_MCP', + 'AQE_LLM_PROVIDER', 'AQE_MAX_BUDGET_USD', +]; + +const VERSION_RE = /(\d+\.\d+\.\d+[^\s)]*)/; + +/** Version from ` --version` — hosts install via many managers (mise, npm, + * standalone), so we ask the CLI rather than read a global package.json. */ +async function hostVersion(bin) { + const r = await run(bin, ['--version'], { timeout: 15_000 }); + if (r.code !== 0) return null; + const m = (r.stdout || r.stderr).match(VERSION_RE); + return m ? m[1] : null; +} + +async function npmLatest(pkg) { + const r = await run('npm', ['view', `${pkg}@latest`, 'version'], { timeout: 20_000 }); + return r.code === 0 ? r.stdout.trim() : null; +} + +/** How a host is installed, so we never clobber a non-npm install: + * 'npm' — an npm global copy exists (we may update it) + * 'external' — on PATH but not the npm global copy (mise/native/brew — advise only) + * 'absent' — not installed at all (we may install it) */ +export async function hostInstallState(host) { + const npmVer = installedVersion(host.pkg); + if (npmVer) return { method: 'npm', version: npmVer }; + if (await have(host.bin)) return { method: 'external', version: await hostVersion(host.bin) }; + return { method: 'absent', version: null }; +} + +/** Install a missing host globally via npm. Intended for the 'absent' case only — + * callers check hostInstallState first so an external install is never shadowed. */ +export async function installHost(id) { + const host = HOSTS.find((h) => h.id === id); + if (!host) return { ok: false, detail: `unknown host: ${id}` }; + const r = await run('npm', ['install', '-g', `${host.pkg}@latest`], { timeout: 600_000 }); + return { ok: r.code === 0, changed: r.code === 0, detail: r.code === 0 ? `installed ${host.pkg}` : r.stderr.split('\n').slice(-2).join(' ').slice(0, 200) }; +} + +/** Update an npm-managed host to latest. No-op guidance for external installs. */ +export async function updateHost(id) { + const host = HOSTS.find((h) => h.id === id); + if (!host) return { ok: false, detail: `unknown host: ${id}` }; + const st = await hostInstallState(host); + if (st.method !== 'npm') return { ok: true, changed: false, detail: `${id} is ${st.method}-managed — update it with your own tool` }; + const r = await run('npm', ['install', '-g', `${host.pkg}@latest`], { timeout: 600_000 }); + return { ok: r.code === 0, changed: r.code === 0, detail: r.code === 0 ? `updated ${host.pkg}` : r.stderr.split('\n').slice(-2).join(' ').slice(0, 200) }; +} + +/** Version drift per host. npm-managed hosts get a live `latest` lookup (network, + * cached by npm); external installs report installed-only (outdated=false, we + * don't own the update). Absent hosts report method 'absent'. */ +export async function hostDrift() { + const out = []; + for (const h of HOSTS) { + const st = await hostInstallState(h); + if (st.method === 'absent') { out.push({ id: h.id, method: 'absent', installed: null, latest: null, outdated: false }); continue; } + const latest = st.method === 'npm' ? await npmLatest(h.pkg) : null; + const outdated = !!(latest && st.version && cmpVersions(latest, st.version) > 0); + out.push({ id: h.id, method: st.method, installed: st.version, latest, outdated }); + } + return out; +} + +/** Detect installed hosts + whether they are currently wired on in `cwd`. */ +export async function detectHosts(cwd = process.cwd()) { + const env = currentEnv(cwd); + const out = {}; + for (const h of HOSTS) { + const present = await have(h.bin); + out[h.id] = { + present, + version: present ? await hostVersion(h.bin) : null, + wired: env[h.enableEnv] === 'true', + }; + } + return out; +} + +/** Detect which API providers have credentials available. */ +export function detectProviders() { + const out = {}; + for (const p of API_PROVIDERS) { + out[p.id] = { keyPresent: p.keyEnv.some((k) => !!process.env[k]) }; + } + return out; +} + +/** Where host-enable env lands: project settings.local.json inside a repo (same + * seam as CLAUDE_FLOW_DB_PATH), else the user settings.json. */ +export function settingsTarget(cwd = process.cwd()) { + const inProject = fs.existsSync(path.join(cwd, '.git')); + return inProject + ? { file: paths.projectSettingsLocal(cwd), scope: 'project' } + : { file: paths.claudeSettingsPath(), scope: 'user' }; +} + +function currentEnv(cwd) { + const { file } = settingsTarget(cwd); + return readJson(file, {})?.env ?? {}; +} + +/** True when providers config is untouched (claude host only, aqe left on its own + * default). Keeps the heal a deliberate no-op so existing users see zero change + * until they opt in. */ +export function isDefault(cfg) { + const p = cfg.providers ?? {}; + return !!p.hosts?.claude && !p.hosts?.codex && p.aqeProvider == null + && (!p.models || p.models.length === 0) && (p.maxBudgetUsd == null) + && (!p.aqeFallback || p.aqeFallback.length === 0); +} + +// ── agentic-qe router config (.agentic-qe/llm-config.json) ────────────────── +// Grounded in aqe's router config-store + types (ADR-123): +// - mergeRouterConfig deep-merges `providers` but SHALLOW-replaces +// `fallbackChain` → ak must write a COMPLETE chain (these scalar defaults). +// - the router iterates `entry.models` → each entry needs populated models. +// - aqe refuses to persist apiKey → ak writes only `enabled` per provider; +// keys stay in the env. +const AQE_CHAIN_DEFAULTS = { maxRetries: 3, retryDelayMs: 100, backoffMultiplier: 2, maxDelayMs: 5000 }; +const AQE_MANAGED_TAG = 'agentic-kit'; + +export function aqeRouterFile(cwd = process.cwd()) { + return path.join(paths.projectAqeDir(cwd), 'llm-config.json'); +} + +/** Map kit.json `aqeFallback` entries → a complete aqe FallbackChain. Priority + * descends by list order (first = highest). Entries carry provider + models. */ +function buildChain(entries) { + return { + id: AQE_MANAGED_TAG, + entries: entries.map((e, i) => ({ + provider: e.provider, + models: e.models ?? [], + enabled: true, + priority: 100 - i * 10, + maxAttempts: 2, + timeoutMs: 30000, + })), + ...AQE_CHAIN_DEFAULTS, + }; +} + +/** Write ak's managed router config: the ordered fallback chain + enabled set + + * default provider, merged into any existing llm-config.json (backup-first, + * never persisting apiKey). No-op unless a fallback chain is configured and we + * are in a project. Returns {ok, changed, detail}. */ +export function applyAqeRouter(cfg, cwd = process.cwd()) { + const chain = cfg.providers?.aqeFallback ?? []; + if (chain.length === 0) return { ok: true, changed: false, detail: 'no aqe fallback chain configured' }; + if (!fs.existsSync(path.join(cwd, '.git'))) return { ok: true, changed: false, detail: 'not a project — aqe router unmanaged' }; + const valid = chain.filter((e) => e?.provider && AQE_PROVIDER_TYPES.includes(e.provider)); + if (valid.length === 0) return { ok: false, detail: 'no valid providers in fallback chain' }; + const file = aqeRouterFile(cwd); + const existing = readJson(file, {}) ?? {}; + const next = { ...existing }; + next._managedBy = AQE_MANAGED_TAG; + next.defaultProvider = cfg.providers.aqeProvider ?? valid[0].provider; + next.providers = { ...(existing.providers ?? {}) }; + for (const e of valid) next.providers[e.provider] = { ...(existing.providers?.[e.provider] ?? {}), enabled: true }; + next.fallbackChain = buildChain(valid); + fs.mkdirSync(path.dirname(file), { recursive: true }); + writeJsonWithBackup(file, next); + const emptyModels = valid.filter((e) => !e.models || e.models.length === 0).map((e) => e.provider); + const warn = emptyModels.length ? ` (⚠ no models for: ${emptyModels.join(', ')})` : ''; + return { ok: true, changed: true, detail: `chain: ${valid.map((e) => e.provider).join(' → ')}${warn}` }; +} + +/** Reversible teardown of ak's router management. Restores the pre-ak file from + * its one-time .bak, or removes an ak-created file. Never touches a file ak + * didn't write (no `_managedBy` tag). */ +export function undoAqeRouter(cwd = process.cwd()) { + const file = aqeRouterFile(cwd); + if (!fs.existsSync(file)) return { ok: true, changed: false, detail: 'no aqe router config' }; + const cur = readJson(file); + if (cur?._managedBy !== AQE_MANAGED_TAG) return { ok: true, changed: false, detail: 'llm-config.json not ak-managed — left as-is' }; + const bak = `${file}.bak`; + if (fs.existsSync(bak)) { + fs.copyFileSync(bak, file); + fs.rmSync(bak, { force: true }); + return { ok: true, changed: true, detail: 'restored pre-ak llm-config.json' }; + } + fs.rmSync(file, { force: true }); + return { ok: true, changed: true, detail: 'removed ak-created llm-config.json' }; +} + +/** The exact env this config wants written. `AQE_LLM_PROVIDER` is written only + * when the user pinned a (valid) aqe provider — otherwise aqe keeps its own + * default/env detection. Omitting a key means "remove if present". */ +export function managedEnv(cfg) { + const p = cfg.providers ?? {}; + const e = { + ENABLE_CLAUDE_CODE: String(!!p.hosts?.claude), + ENABLE_CODEX: String(!!p.hosts?.codex), + }; + if (cfg.aqe !== false && p.aqeProvider && AQE_PROVIDER_TYPES.includes(p.aqeProvider)) { + e.AQE_LLM_PROVIDER = p.aqeProvider; + } + if (p.maxBudgetUsd != null) e.AQE_MAX_BUDGET_USD = String(p.maxBudgetUsd); + return e; +} + +/** Reconcile the managed env keys in the target settings file to match `cfg`. + * Idempotent, backup-first, merge-not-clobber. Returns {ok, detail, changed}. */ +export function applyHosts(cfg, cwd = process.cwd()) { + if (isDefault(cfg)) return { ok: true, changed: false, detail: 'claude-only (default) — nothing to wire' }; + const { file, scope } = settingsTarget(cwd); + const desired = managedEnv(cfg); + const s = readJson(file, {}) ?? {}; + s.env ??= {}; + let changed = false; + for (const k of MANAGED_ENV_KEYS) { + if (k in desired) { + if (s.env[k] !== desired[k]) { s.env[k] = desired[k]; changed = true; } + } else if (k in s.env) { delete s.env[k]; changed = true; } + } + if (changed) writeJsonWithBackup(file, s); + const on = HOSTS.filter((h) => cfg.providers.hosts[h.id]).map((h) => h.id).join('+') || 'none'; + return { ok: true, changed, detail: `hosts=${on} (${scope}${changed ? ', written' : ', in sync'})` }; +} + +/** Register configured API-key providers with ruflo (keys read from env, never + * passed here). Idempotent — ruflo upserts. Returns {ok, detail}. */ +export async function applyProviders(cfg, cwd = process.cwd()) { + const models = cfg.providers?.models ?? []; + if (models.length === 0) return { ok: true, changed: false, detail: 'no API-key providers configured' }; + if (!(await have('ruflo'))) return { ok: false, detail: 'ruflo not on PATH' }; + const done = []; + for (const m of models) { + if (!m?.id) continue; + const args = ['providers', 'configure', '-p', m.id]; + if (m.model) args.push('-m', m.model); + const r = await run('ruflo', args, { cwd, timeout: 60_000 }); + done.push(`${m.id}${r.code === 0 ? '' : '(failed)'}`); + } + return { ok: done.every((d) => !d.includes('failed')), changed: true, detail: `configured: ${done.join(', ')}` }; +} + +/** Heavy, pick/setup-time only: regenerate dual-mode agents when codex is on. + * Kept OUT of the sync hot path (it force-regenerates project files). */ +export async function ensureDualAgents(cfg, cwd = process.cwd()) { + if (!cfg.providers?.hosts?.codex) return { ok: true, changed: false, detail: 'codex disabled — no dual agents' }; + if (!fs.existsSync(path.join(cwd, '.git'))) return { ok: true, changed: false, detail: 'not a project — skipped `ruflo init --dual`' }; + if (!(await have('ruflo'))) return { ok: false, detail: 'ruflo not on PATH' }; + const r = await run('ruflo', ['init', '--dual', '--force'], { cwd, timeout: 300_000 }); + return { ok: r.code === 0, changed: r.code === 0, detail: r.code === 0 ? 'ruflo init --dual applied' : 'ruflo init --dual failed' }; +} + +/** Reversible teardown: strip every managed env key from the target file. */ +export function undoProviders(cwd = process.cwd()) { + const { file } = settingsTarget(cwd); + const s = readJson(file); + if (!s?.env) return { ok: true, changed: false, detail: 'nothing wired' }; + let removed = 0; + for (const k of MANAGED_ENV_KEYS) if (k in s.env) { delete s.env[k]; removed++; } + if (removed) writeJsonWithBackup(file, s); + return { ok: true, changed: removed > 0, detail: `${removed} managed env key(s) removed` }; +} diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index 80d3bf8e..76176d75 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -59,7 +59,13 @@ export async function driftReport({ force = false } = {}) { const cfg = loadKitConfig(); const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000; const fresh = !force && cfg.versionCheck?.last && Date.now() - cfg.versionCheck.last < ttlMs; - const pkgs = ['ruflo', 'agentic-qe']; + // Frontier host CLIs are kept current only when npm-managed (a global + // package.json exists). External installs (mise/native/brew) have no global + // package.json → installedVersion is null → filtered out here, so ak never + // claims to manage an update it doesn't own. Pkg names mirror HOSTS in + // providers.mjs (kept local to avoid an import cycle). + const HOST_PKGS = ['@anthropic-ai/claude-code', '@openai/codex']; + const pkgs = ['ruflo', 'agentic-qe', ...HOST_PKGS.filter((p) => installedVersion(p))]; const report = []; let latest = cfg.versionCheck?.seen ?? {}; if (!fresh) { diff --git a/tests/kit/providers.test.mjs b/tests/kit/providers.test.mjs new file mode 100644 index 00000000..43677f38 --- /dev/null +++ b/tests/kit/providers.test.mjs @@ -0,0 +1,224 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { readJson } from '../../src/lib/settings.mjs'; +import { loadKitConfig } from '../../src/lib/config.mjs'; +import { + isDefault, managedEnv, applyHosts, undoProviders, MANAGED_ENV_KEYS, + HOSTS, installHost, updateHost, applyAqeRouter, undoAqeRouter, aqeRouterFile, +} from '../../src/lib/providers.mjs'; + +// A tmp dir with a .git marker → settingsTarget() writes the ISOLATED +// project settings.local.json instead of the real ~/.claude/settings.json. +function tmpProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-prov-')); + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + return dir; +} +const localFile = (dir) => path.join(dir, '.claude', 'settings.local.json'); +const rm = (dir) => fs.rmSync(dir, { recursive: true, force: true }); + +function defaultCfg() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-')); + const cfg = loadKitConfig(path.join(dir, 'kit.json')); + rm(dir); + return cfg; +} + +test('isDefault is true for the claude-only default config', () => { + assert.equal(isDefault(defaultCfg()), true); +}); + +test('isDefault is false once codex is enabled', () => { + const cfg = defaultCfg(); + cfg.providers.hosts.codex = true; + assert.equal(isDefault(cfg), false); +}); + +test('managedEnv leaves AQE_LLM_PROVIDER unset when no aqe provider is pinned', () => { + const cfg = defaultCfg(); + cfg.providers.hosts.codex = true; // host axis is independent of the aqe provider + const env = managedEnv(cfg); + assert.equal(env.ENABLE_CLAUDE_CODE, 'true'); + assert.equal(env.ENABLE_CODEX, 'true'); + assert.equal('AQE_LLM_PROVIDER' in env, false, 'unset aqeProvider → aqe keeps its own default'); +}); + +test('managedEnv writes AQE_LLM_PROVIDER for any supported provider (not just claude-code)', () => { + for (const provider of ['claude-code', 'openai', 'gemini', 'ollama']) { + const cfg = defaultCfg(); + cfg.providers.aqeProvider = provider; + assert.equal(managedEnv(cfg).AQE_LLM_PROVIDER, provider, `${provider} wired`); + } +}); + +test('managedEnv ignores an unknown aqe provider value', () => { + const cfg = defaultCfg(); + cfg.providers.aqeProvider = 'bogus-model'; + assert.equal('AQE_LLM_PROVIDER' in managedEnv(cfg), false); +}); + +test('managedEnv adds AQE_MAX_BUDGET_USD only when a budget is set', () => { + const cfg = defaultCfg(); + cfg.providers.hosts.codex = true; + cfg.providers.maxBudgetUsd = 5; + assert.equal(managedEnv(cfg).AQE_MAX_BUDGET_USD, '5'); +}); + +test('applyHosts is a no-op at the claude-only default (writes nothing)', () => { + const dir = tmpProject(); + const res = applyHosts(defaultCfg(), dir); + assert.equal(res.changed, false); + assert.equal(fs.existsSync(localFile(dir)), false, 'no settings file created at default'); + rm(dir); +}); + +test('applyHosts writes managed env into project settings.local.json when codex is enabled', () => { + // Arrange: an unrelated pre-existing env key must survive (merge-not-clobber) + const dir = tmpProject(); + fs.mkdirSync(path.dirname(localFile(dir)), { recursive: true }); + fs.writeFileSync(localFile(dir), JSON.stringify({ env: { FOO: 'bar' } })); + const cfg = defaultCfg(); + cfg.providers.hosts.codex = true; + // Act + const res = applyHosts(cfg, dir); + // Assert + assert.equal(res.changed, true); + const env = readJson(localFile(dir)).env; + assert.equal(env.ENABLE_CODEX, 'true'); + assert.equal(env.ENABLE_CLAUDE_CODE, 'true'); + assert.equal(env.FOO, 'bar', 'unrelated env key preserved'); + rm(dir); +}); + +test('applyHosts is idempotent — second run reports no change', () => { + const dir = tmpProject(); + const cfg = defaultCfg(); + cfg.providers.hosts.codex = true; + applyHosts(cfg, dir); + const second = applyHosts(cfg, dir); + assert.equal(second.changed, false); + rm(dir); +}); + +test('undoProviders strips every managed key and leaves others intact', () => { + const dir = tmpProject(); + const cfg = defaultCfg(); + cfg.providers.hosts.codex = true; + applyHosts(cfg, dir); + // seed an unrelated key alongside the managed ones + const seeded = readJson(localFile(dir)); + seeded.env.CLAUDE_FLOW_DB_PATH = '/x/memory.db'; + fs.writeFileSync(localFile(dir), JSON.stringify(seeded)); + // Act + const res = undoProviders(dir); + // Assert + assert.equal(res.changed, true); + const env = readJson(localFile(dir)).env; + for (const k of MANAGED_ENV_KEYS) assert.equal(k in env, false, `${k} removed`); + assert.equal(env.CLAUDE_FLOW_DB_PATH, '/x/memory.db', 'unrelated key survives undo'); + rm(dir); +}); + +test('applyAqeRouter writes a complete ak-managed fallback chain to llm-config.json', () => { + const dir = tmpProject(); + const cfg = defaultCfg(); + cfg.providers.aqeProvider = 'claude-code'; + cfg.providers.aqeFallback = [ + { provider: 'claude-code', models: ['claude-opus-4-8'] }, + { provider: 'openai', models: ['gpt-5.6', 'gpt-5.6-terra'] }, + ]; + const res = applyAqeRouter(cfg, dir); + assert.equal(res.changed, true); + const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(disk._managedBy, 'agentic-kit'); + assert.equal(disk.defaultProvider, 'claude-code'); + assert.deepEqual(disk.fallbackChain.entries.map((e) => e.provider), ['claude-code', 'openai']); + assert.equal(disk.fallbackChain.entries[0].priority > disk.fallbackChain.entries[1].priority, true, 'priority descends by order'); + assert.equal(disk.fallbackChain.maxRetries, 3, 'complete chain carries scalar defaults'); + assert.equal(disk.providers.openai.enabled, true); + assert.equal('apiKey' in (disk.providers.openai), false, 'never persists apiKey'); + rm(dir); +}); + +test('applyAqeRouter is a no-op with no fallback chain configured', () => { + const dir = tmpProject(); + const res = applyAqeRouter(defaultCfg(), dir); + assert.equal(res.changed, false); + assert.equal(fs.existsSync(aqeRouterFile(dir)), false); + rm(dir); +}); + +test('undoAqeRouter removes an ak-created llm-config.json (no prior file)', () => { + const dir = tmpProject(); + const cfg = defaultCfg(); + cfg.providers.aqeFallback = [{ provider: 'openai', models: ['gpt-5.6'] }]; + applyAqeRouter(cfg, dir); + assert.equal(fs.existsSync(aqeRouterFile(dir)), true); + const res = undoAqeRouter(dir); + assert.equal(res.changed, true); + assert.equal(fs.existsSync(aqeRouterFile(dir)), false, 'ak-created file removed'); + rm(dir); +}); + +test('undoAqeRouter restores a pre-existing user llm-config.json from backup', () => { + const dir = tmpProject(); + const file = aqeRouterFile(dir); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ defaultProvider: 'gemini', userKey: 'keep-me' })); + const cfg = defaultCfg(); + cfg.providers.aqeFallback = [{ provider: 'openai', models: ['gpt-5.6'] }]; + applyAqeRouter(cfg, dir); // overwrites, backs up first + const restored = undoAqeRouter(dir); + assert.equal(restored.changed, true); + const back = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(back.userKey, 'keep-me', 'user config restored from .bak'); + assert.equal('_managedBy' in back, false); + rm(dir); +}); + +test('undoAqeRouter leaves a foreign (non-ak) llm-config.json untouched', () => { + const dir = tmpProject(); + const file = aqeRouterFile(dir); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ defaultProvider: 'gemini' })); + const res = undoAqeRouter(dir); + assert.equal(res.changed, false); + assert.equal(fs.existsSync(file), true, 'foreign file left as-is'); + rm(dir); +}); + +test('every host descriptor carries an npm package name for install/update', () => { + for (const h of HOSTS) assert.equal(typeof h.pkg, 'string', `${h.id} has pkg`); +}); + +test('installHost rejects an unknown host id without shelling out', async () => { + const r = await installHost('bogus'); + assert.equal(r.ok, false); + assert.match(r.detail, /unknown host/); +}); + +test('updateHost rejects an unknown host id without shelling out', async () => { + const r = await updateHost('bogus'); + assert.equal(r.ok, false); + assert.match(r.detail, /unknown host/); +}); + +test('config merge: providers partial merges over defaults (hosts deep-merged)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-merge-')); + const f = path.join(dir, 'kit.json'); + fs.writeFileSync(f, JSON.stringify({ providers: { hosts: { codex: true } } })); + const cfg = loadKitConfig(f); + assert.equal(cfg.providers.hosts.codex, true, 'user value applied'); + assert.equal(cfg.providers.hosts.claude, true, 'unspecified host keeps default'); + assert.equal(cfg.providers.aqeProvider, null, 'unspecified field keeps default'); + rm(dir); +}); + +test('isDefault is false once an aqe provider is pinned (independent of hosts)', () => { + const cfg = defaultCfg(); + cfg.providers.aqeProvider = 'openai'; + assert.equal(isDefault(cfg), false); +});