diff --git a/aidd_docs/memory/cli.md b/aidd_docs/memory/cli.md index f335de8c6..e8e5e893c 100644 --- a/aidd_docs/memory/cli.md +++ b/aidd_docs/memory/cli.md @@ -6,6 +6,7 @@ The `aidd` binary, built from `cli/` and published to npm as `@ai-driven-dev/cli ## Commands +- `setup --provider` chooses the provider the main session and each IDE's agents run on, machine-wide: [agent guide](../../docs/SUBAGENTS.md#-models). - `telemetry` counts the tokens of Claude Code and Codex sessions locally: [usage contract](../product/usage-contract.md). - No list here. The surface moves; `aidd --help` is the only reading that stays true. diff --git a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md index e3678dcba..1c869a5b1 100644 --- a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md @@ -6,12 +6,14 @@ Behavior and sources are in [the agent guide](../../../../docs/SUBAGENTS.md). - **Agents:** explorer, executor, validator and reviewer ship in `plugins/aidd-orchestrator/agents/`, installed with the plugin. No tool list is declared: every host gives an agent all tools by default. - **Translation:** each host receives only the keys its documentation lists: name or file path as the ID, description, model, and Claude's `effort` under the host's own key. Each host has one agent frontmatter function, shared by the plugin install and its built distributions. -- **Out of scope:** per-provider models and a model for the main session. +- **Models:** `aidd setup --provider` records a provider per IDE; every build that installs agents gives each role its tier's model from `agent-models.json`, and the main session's model goes in the IDE's user settings. ## Checks - **Claude Code:** the four agents load and run from a project-scoped plugin install. - **OpenCode:** the four agents run from the flat distribution through `opencode run --standalone --agent ` with an OpenAI model, each answering after a `read` call. - **Codex:** the four agents load from the flat distribution in a trusted project; `codex exec` spawned each, and its session records the agent's role and instructions. +- **Provider models:** a built OpenCode agent with `openai` loaded as `{id: gpt-6-luna, providerID: openai, variant: medium}` (`opencode debug agents`), and a Codex session spawned the explorer on `gpt-6-luna` while its parent ran `gpt-6.1-sol`. +- **Setter:** in a scratch project, `aidd setup --provider anthropic` gave the reviewer `claude-opus-5-5` and wrote the main session's `claude-sonnet-5-5` in the user settings of Claude Code and OpenCode; `doctor` stayed healthy and `sync` passed; `--provider none` removed every model. - **Every host:** `cli/tests/e2e/agent-install.e2e.test.ts` builds the repository flat and checks each shipped agent's path, frontmatter and body against the host's documented format. - **Copilot, Cursor, Kilo:** no host binary in the test environment, so loading by the host is unverified. The Copilot plugin lists agent files where its documentation describes agent directories. diff --git a/cli/README.md b/cli/README.md index 60f06725f..d6b35c646 100644 --- a/cli/README.md +++ b/cli/README.md @@ -33,11 +33,15 @@ Check: `aidd --version`. `setup` writes the manifest, the default marketplace, the tool configs and the plugins. `--global` installs for the machine instead, and writes nothing under the project. +`--provider` runs the main session and the framework's agents on that provider's models, reports which model each runs on, and `--provider none` undoes it. + +The model ids are pinned in [`agent-models.json`](src/contexts/tools/domain/formats/agent-models.json), checked against each provider's model page on 2026-11-04. When a provider ships or retires a model, update that file, the table in [the agent guide](../docs/SUBAGENTS.md#-models), and the tests that name the ids. | Command | Lands in | | --- | --- | | `aidd setup --ide claude` | `.claude/` and `.aidd/` in this project | | `aidd setup --global --ide opencode --plugins all` | `~/.config/opencode/` and `~/.config/aidd/`, nothing in this project | +| `aidd setup --ide opencode --provider mistral` | `.opencode/agents/` with Mistral models; the main session's in `~/.config/opencode/opencode.json`; the provider in `~/.config/aidd/agent-models.json`, for every project | ## Commands @@ -135,9 +139,11 @@ Install it, then run `aidd sync --global`. ```sh aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes aidd sync --global # rebuild the copied files and refresh the hosts from the framework source -aidd clean --global --force # remove what --global installed and purge the shared source; without --force it previews or asks +aidd clean --global --force # remove what --global installed, purge the shared source, forget the provider and restore the main session's model; without --force it previews or asks ``` +With `--provider`, the copied agents and enabled plugins carry that provider's models. + ## Authentication Needed for a private marketplace only. diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index 86da7d334..6e0bbdbd2 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -13,6 +13,7 @@ src/ │ │ └── infrastructure/ │ ├── framework/ # the installation record and everything done to a project │ │ ├── application/ +│ │ │ ├── agents/ # which model each host's agents run on │ │ │ ├── clean/ │ │ │ ├── doctor/ │ │ │ ├── flows/ diff --git a/cli/package.json b/cli/package.json index 5cf2b56ec..59dc6906e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 734, + "bundleBudgetKB": 741, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index ebefc8aee..0841164b1 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -51,6 +51,8 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 0.2% measured headroom. // 734 KB: 718.8 KB, the machine-wide install of `setup --global` (+8.0 KB over the 710.8 KB // above); 2.1% measured headroom. +// 741 KB: 725.6 KB, the agents' models: a provider per IDE that every install build applies +// (+6.8 KB over the 718.8 KB above); 2.1% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index 63afd9111..f017dab17 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -225,6 +225,13 @@ run "telemetry forget (preview)" 0 "Nothing was removed" "$P_TEL" -- telemetry f run "telemetry forget --yes" 0 "Removed" "$P_TEL" -- telemetry forget --yes run "telemetry off (after forget)" 0 "Measurement was not on" "$P_TEL" -- telemetry off +# The provider lands in the sandboxed $AIDD_USER_CONFIG_DIR, never the real profile's. +section "setup --provider (sandboxed profile)" +P_AGENTS=$(new_project) +SETUP_OC=(setup --source local --path "$FRAMEWORK_FIXTURE" --ide opencode --plugins none --yes) +run "setup --provider" 0 "Models on mistral" "$P_AGENTS" -- "${SETUP_OC[@]}" --provider mistral +run "setup --provider none" 0 "run on the IDE's model again" "$P_AGENTS" -- "${SETUP_OC[@]}" --provider none + section "update --check" out=$(cd "$ROOT" && node "$CLI" update --check 2>&1); rc=$? if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --check (exit $rc)"; else bad "update crashed (exit $rc)" "$out"; fi diff --git a/cli/src/contexts/framework/application/agents/set-agent-model-use-case.ts b/cli/src/contexts/framework/application/agents/set-agent-model-use-case.ts new file mode 100644 index 000000000..dca4956bf --- /dev/null +++ b/cli/src/contexts/framework/application/agents/set-agent-model-use-case.ts @@ -0,0 +1,234 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { MarketplaceScope } from "../../../../kernel/scope.js"; +import type { EnvironmentReader } from "../../../tools/domain/capabilities/plugins-capability.js"; +import { + AGENT_MODELS, + type AgentHost, + type AgentProvider, + supportedProvider, +} from "../../../tools/domain/formats/agent-models.js"; +import { + mainModelValues, + planMainModelInstall, + planMainModelRemoval, +} from "../../../tools/domain/formats/main-model-config.js"; +import { globalFilesOf, userMachineLocalFilesOf } from "../../../tools/domain/registry.js"; +import type { AgentModelStore } from "../../domain/ports/agent-model-store.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { MarketplaceSyncSettings } from "../flows/marketplace-sync-settings-use-case.js"; +import type { PluginUpdateUseCase } from "../plugin/plugin-update-use-case.js"; + +export interface SetAgentModelOptions { + readonly projectRoot: string; + readonly hosts: readonly AgentHost[]; + /** Absent, the hosts go back to the IDE's model. */ + readonly provider?: string; + /** `"user"` also refreshes what `setup --global` installed; the provider and the main + * session's model hold for the whole machine either way. */ + readonly scope: MarketplaceScope; +} + +export interface SetAgentModelResult { + /** Plugins this project re-installed with the new models. */ + readonly reinstalled: readonly string[]; + /** Each host whose main session's settings file this run changed, and that file. */ + readonly changed: readonly { readonly host: AgentHost; readonly path: string }[]; + /** Each host whose settings file was left as it is, and why. */ + readonly skipped: readonly { + readonly host: AgentHost; + readonly path: string; + readonly reason: string; + }[]; +} + +interface FileChange { + readonly host: AgentHost; + readonly path: string; + readonly content: string | null; +} + +/** + * The setter for agent models, run once the install has succeeded. Records the provider each + * host's agents run on, which every later build of that host applies, re-installs this + * project's plugins so they carry it, then points the host's main session at the provider's + * main-tier model in the host's own user settings, machine-wide like the provider and never a + * file a project tracks, recording what that replaced so unsetting restores it. + */ +export class SetAgentModelUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly store: AgentModelStore, + private readonly manifestRepo: ManifestRepository, + private readonly userManifestRepo: ManifestRepository, + private readonly pluginUpdate: Pick, + private readonly userPluginUpdate: Pick, + private readonly activation: MarketplaceSyncSettings, + private readonly homedir: () => string, + private readonly env: EnvironmentReader + ) {} + + async execute(options: SetAgentModelOptions): Promise { + const chosen = this.chosenProviders(options); + const before = await this.store.loadProviders(); + const providers = { ...before }; + for (const host of options.hosts) { + const provider = chosen.get(host); + if (provider === undefined) delete providers[host]; + else providers[host] = provider; + } + await this.store.saveProviders(providers); + let reinstalled: readonly string[]; + try { + reinstalled = await this.reinstall(options); + } catch (error) { + // A rebuild that failed leaves the machine on the provider it had. + await this.store.saveProviders(before); + throw error; + } + return { reinstalled, ...(await this.applyMainModels(options.hosts, chosen)) }; + } + + /** `clean --global`: forgets every host's provider and gives each main session back what it + * held before AIDD, machine-wide. `dryRun` says what it would restore and changes nothing. */ + async reset(options: { + readonly dryRun: boolean; + }): Promise< + Pick & { readonly providers: readonly AgentHost[] } + > { + const providers = Object.keys(await this.store.loadProviders()) as AgentHost[]; + const hosts = Object.keys(await this.store.loadMainModels()) as AgentHost[]; + const restored = await this.applyMainModels(hosts, new Map(), options.dryRun); + if (!options.dryRun) await this.store.saveProviders({}); + return { ...restored, providers }; + } + + /** Points each host's main session at the provider, or restores what it replaced once none + * is chosen. A settings file it cannot safely rewrite is skipped, never half-written. */ + private async applyMainModels( + hosts: readonly AgentHost[], + chosen: ReadonlyMap, + dryRun = false + ): Promise> { + const mainModels = await this.store.loadMainModels(); + const changes: FileChange[] = []; + const skipped: { host: AgentHost; path: string; reason: string }[] = []; + for (const host of hosts) { + const provider = chosen.get(host); + const record = mainModels[host]; + const current = await this.settingsPath(host); + let path = record?.path ?? current; + try { + // Restored where it was written, once unset or once the host reads another file. + if (record !== undefined && (provider === undefined || record.path !== current)) { + const content = await this.readIfAny(record.path); + const next = planMainModelRemoval(content, record); + delete mainModels[host]; + if (next !== content) changes.push({ host, path: record.path, content: next }); + } + if (provider === undefined || current === undefined) continue; + path = current; + const plan = planMainModelInstall( + current, + await this.readIfAny(current), + mainModelValues(host, provider), + mainModels[host] + ); + mainModels[host] = plan.record; + changes.push({ host, path: current, content: plan.content }); + } catch (error) { + if (path !== undefined) skipped.push({ host, path, reason: (error as Error).message }); + } + } + const result = { changed: changes.map(({ host, path }) => ({ host, path })), skipped }; + if (dryRun) return result; + // The record goes first: a run stopped between two settings files still knows what each + // held before, so unsetting restores it instead of taking AIDD's value for the person's. + await this.store.saveMainModels(mainModels); + for (const { path, content } of changes) { + if (content === null) await this.fs.deleteFile(path); + else await this.fs.writeFile(path, content); + } + return result; + } + + private async readIfAny(path: string): Promise { + return (await this.fs.fileExists(path)) ? this.fs.readFile(path) : null; + } + + private chosenProviders( + options: Pick + ): Map { + const chosen = new Map(); + if (options.provider !== undefined) { + for (const host of options.hosts) chosen.set(host, supportedProvider(host, options.provider)); + } + return chosen; + } + + /** The host's own user settings file; a host declaring none there reads `main.file`, or its + * JSONC twin when only that exists, from the directory a machine-wide install copies its + * files into. Absent where neither exists. */ + private async settingsPath(host: AgentHost): Promise { + const settings = AGENT_MODELS.hosts[host].main; + if (settings === undefined) return undefined; + const home = this.homedir(); + const own = userMachineLocalFilesOf(host, home, this.env)[0]; + if (own !== undefined) return own; + const globalDir = globalFilesOf(host)?.dir(home); + if (globalDir === undefined) return undefined; + const path = join(globalDir, settings.file); + const twin = `${path}c`; + return !(await this.fs.fileExists(path)) && (await this.fs.fileExists(twin)) ? twin : path; + } + + /** Files a plugin wrote are rewritten from the rebuilt tree, and a host that installs plugins + * itself is driven again, which hands it that tree. The provider is machine-wide, so this + * project is always refreshed, and a plugin installed in the user's home, as Cursor's always + * is, too; `--global` adds the machine-scope registrations. */ + private async reinstall(options: SetAgentModelOptions): Promise { + const rewritten = new Set(); + const userManifest = await this.userManifestRepo.load(); + const userPlugins = options.hosts.filter((host) => + userManifest?.getPlugins(host).some((plugin) => plugin.scope === "user") + ); + if (userPlugins.length > 0) { + const names = await this.userPluginUpdate.execute({ + toolIds: userPlugins, + projectRoot: options.projectRoot, + scope: "user", + rebuild: true, + }); + for (const name of names) rewritten.add(name); + } + const manifest = await this.manifestRepo.load(); + const installed = options.hosts.filter((host) => manifest?.hasTool(host) === true); + const projectPlugins = installed.filter((host) => + manifest?.getPlugins(host).some((plugin) => plugin.scope !== "user") + ); + if (projectPlugins.length > 0) { + const names = await this.pluginUpdate.execute({ + toolIds: projectPlugins, + projectRoot: options.projectRoot, + rebuild: true, + }); + for (const name of names) rewritten.add(name); + } + if (installed.length > 0) await this.activate(options.projectRoot, installed); + if (options.scope === "user") { + const machine = options.hosts.filter((host) => userManifest?.hasTool(host) === true); + if (machine.length > 0) await this.activate(options.projectRoot, machine, true); + } + return [...rewritten]; + } + + private async activate(projectRoot: string, toolIds: AgentHost[], user = false): Promise { + const { errors } = await this.activation.execute({ + projectRoot, + toolIds, + ...(user ? { scope: "user" as const, manifestRepo: this.userManifestRepo } : {}), + }); + if (errors.length > 0) throw new Error(errors.map((error) => error.message).join("\n")); + } +} diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts index 1af12e092..d51d0cff5 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -31,8 +31,7 @@ export interface PluginUpdateOptions { toolIds: AiToolId[] | "all"; projectRoot: string; scope?: "project" | "user"; - /** Re-materialize at the same version: the source changed, not the plugin's version. Read by - * the machine-scope update, which `sync --global` drives. */ + /** Re-materialize at the same version: the build changed, not the plugin. */ rebuild?: boolean; } @@ -60,7 +59,8 @@ export class PluginUpdateUseCase { pluginNames, projectRoot, cacheDir, - manifest + manifest, + options.rebuild === true ); updated.push(...names); } @@ -73,7 +73,8 @@ export class PluginUpdateUseCase { pluginNames: string[] | undefined, projectRoot: string, cacheDir: string, - manifest: Manifest + manifest: Manifest, + rebuild: boolean ): Promise { const plugins = manifest.getPlugins(toolId); const targets = pluginNames @@ -81,7 +82,14 @@ export class PluginUpdateUseCase { : plugins.filter((plugin) => plugin.scope !== "user"); const updated: string[] = []; for (const plugin of targets) { - const didUpdate = await this.updateOnePlugin(plugin, toolId, projectRoot, cacheDir, manifest); + const didUpdate = await this.updateOnePlugin( + plugin, + toolId, + projectRoot, + cacheDir, + manifest, + rebuild + ); if (didUpdate) updated.push(plugin.name); } return updated; @@ -92,14 +100,15 @@ export class PluginUpdateUseCase { toolId: AiToolId, projectRoot: string, cacheDir: string, - manifest: Manifest + manifest: Manifest, + rebuild: boolean ): Promise { if (plugin.scope === "user") throw new InvalidPluginScopeError(toolId, "project", "user"); const dist = await this.distributionLoader.load(plugin.source, cacheDir, { forceRefresh: true, }); await this.pluginRuntime?.ensurePluginRuntimeFiles(toolId, projectRoot, manifest); - if (compareSemver(dist.manifest.version, plugin.version) <= 0) return false; + if (!rebuild && compareSemver(dist.manifest.version, plugin.version) <= 0) return false; await this.replacePluginFiles(plugin, dist, toolId, projectRoot, manifest); return true; } diff --git a/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts index 09cf30f4c..1659373cb 100644 --- a/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts +++ b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts @@ -14,13 +14,18 @@ import type { FrameworkBuildMode } from "../../../tools/domain/registry.js"; import type { FrameworkBuild } from "../../../translate/application/translate-source.js"; import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js"; -/** `undefined` when the target/mode pair has no build. */ +/** `undefined` when the target/mode pair has no build. With a provider, every agent gets its + * role's model there. */ export type FrameworkBuildFor = ( target: FrameworkBuildTarget, mode: FrameworkBuildMode, - outDir: string + outDir: string, + provider?: string ) => FrameworkBuild | undefined; +/** The provider chosen for a target's agents, if any. */ +export type AgentProviderFor = (target: FrameworkBuildTarget) => Promise; + export interface EnsureBuiltMarketplaceOptions { readonly projectRoot: string; readonly marketplace: Marketplace; @@ -60,7 +65,9 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { * happened to register it would tie a declaration meant for every project to the life of one * of them: delete that project and the global registration points at nothing. */ - private readonly userCacheRoot: () => string + private readonly userCacheRoot: () => string, + /** A changed provider changes the built agents, so it is part of what keeps a cache fresh. */ + private readonly agentProviderFor: AgentProviderFor = async () => undefined ) {} async execute(options: EnsureBuiltMarketplaceOptions): Promise { @@ -82,7 +89,8 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { projectRoot: options.projectRoot, forceRefresh: options.forceRefresh, }); - const sentinel = this.sentinelValue(resolved.catalog?.version); + const provider = await this.agentProviderFor(options.target); + const sentinel = this.sentinelValue(resolved.catalog?.version, provider); const memoKey = `${options.marketplace.name}:${options.target}:${sentinel}`; const memoized = this.memo.get(memoKey); if (memoized !== undefined) return memoized; @@ -91,14 +99,16 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { builtDir, resolve(resolved.localPath), sentinel, - this.versionIsTrustworthy(options) + this.versionIsTrustworthy(options), + provider ); this.memo.set(memoKey, result); return result; } - private sentinelValue(catalogVersion: string | undefined): string { - return `${this.version.get()}:${catalogVersion ?? UNVERSIONED}`; + private sentinelValue(catalogVersion: string | undefined, provider: string | undefined): string { + const base = `${this.version.get()}:${catalogVersion ?? UNVERSIONED}`; + return provider === undefined ? base : `${base}:${provider}`; } /** @@ -120,13 +130,14 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { builtDir: string, sourceDir: string, sentinel: string, - versionIsTrustworthy: boolean + versionIsTrustworthy: boolean, + provider: string | undefined ): Promise { const version = sentinel.split(":")[1]; if (versionIsTrustworthy && (await this.isFresh(builtDir, sentinel))) { return { builtDir, version, rebuilt: false }; } - await this.build(options.target, options.mode, sourceDir, builtDir); + await this.build(options.target, options.mode, sourceDir, builtDir, provider); await this.fs.writeFile(join(builtDir, SENTINEL_FILE), sentinel); return { builtDir, version, rebuilt: true }; } @@ -143,13 +154,14 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { target: FrameworkBuildTarget, mode: FrameworkBuildMode, sourceDir: string, - builtDir: string + builtDir: string, + provider: string | undefined ): Promise { if (this.nested(sourceDir, builtDir)) { - await this.buildViaTemp(target, mode, sourceDir, builtDir); + await this.buildViaTemp(target, mode, sourceDir, builtDir, provider); return; } - await this.runBuild(target, mode, sourceDir, builtDir); + await this.runBuild(target, mode, sourceDir, builtDir, provider); } private nested(sourceDir: string, builtDir: string): boolean { @@ -160,11 +172,12 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { target: FrameworkBuildTarget, mode: FrameworkBuildMode, sourceDir: string, - builtDir: string + builtDir: string, + provider: string | undefined ): Promise { const temp = join(tmpdir(), `aidd-built-${target}-${mode}`); await this.fs.deleteDirectory(temp); - await this.runBuild(target, mode, sourceDir, temp); + await this.runBuild(target, mode, sourceDir, temp, provider); await this.fs.deleteDirectory(builtDir); await this.copyDir(temp, builtDir); await this.fs.deleteDirectory(temp); @@ -177,10 +190,11 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { target: FrameworkBuildTarget, mode: FrameworkBuildMode, sourceDir: string, - outDir: string + outDir: string, + provider: string | undefined ): Promise { await this.fs.createDirectory(outDir); - const build = this.buildFor(target, mode, outDir); + const build = this.buildFor(target, mode, outDir, provider); if (build === undefined) { throw new Error(`No framework build for target '${target}' mode '${mode}'.`); } diff --git a/cli/src/contexts/framework/domain/ports/agent-model-store.ts b/cli/src/contexts/framework/domain/ports/agent-model-store.ts new file mode 100644 index 000000000..2867c91c4 --- /dev/null +++ b/cli/src/contexts/framework/domain/ports/agent-model-store.ts @@ -0,0 +1,17 @@ +import type { AgentHost, AgentProvider } from "../../../tools/domain/formats/agent-models.js"; +import type { MainModelRecord } from "../../../tools/domain/formats/main-model-config.js"; + +/** The provider each host's agents run on. Machine-wide, like the built framework they ship in. */ +export type AgentProviders = Partial>; + +/** The main-session settings AIDD wrote per host, so unsetting restores them. */ +export type MainModels = Partial>; + +/** `agent-models.json` in AIDD's user configuration directory holds the providers; + * `main-model.json` beside it the main models written. Both are machine-wide. */ +export interface AgentModelStore { + loadProviders(): Promise; + saveProviders(providers: AgentProviders): Promise; + loadMainModels(): Promise; + saveMainModels(models: MainModels): Promise; +} diff --git a/cli/src/contexts/framework/infrastructure/agent-model-store-adapter.ts b/cli/src/contexts/framework/infrastructure/agent-model-store-adapter.ts new file mode 100644 index 000000000..7227e2e83 --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/agent-model-store-adapter.ts @@ -0,0 +1,53 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { + AgentModelStore, + AgentProviders, + MainModels, +} from "../domain/ports/agent-model-store.js"; + +export class AgentModelStoreAdapter implements AgentModelStore { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly userConfigDir: () => string + ) {} + + loadProviders(): Promise { + return this.read(join(this.userConfigDir(), "agent-models.json")); + } + + saveProviders(providers: AgentProviders): Promise { + return this.write(join(this.userConfigDir(), "agent-models.json"), providers); + } + + loadMainModels(): Promise { + return this.read(join(this.userConfigDir(), "main-model.json")); + } + + saveMainModels(models: MainModels): Promise { + return this.write(join(this.userConfigDir(), "main-model.json"), models); + } + + private async read(path: string): Promise { + if (!(await this.fs.fileExists(path))) return {} as T; + let parsed: unknown; + try { + parsed = JSON.parse(await this.fs.readFile(path)); + } catch (error) { + throw new Error(`${path} is not valid JSON: ${(error as Error).message}`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${path} must contain a JSON object.`); + } + return parsed as T; + } + + private async write(path: string, value: object): Promise { + if (Object.keys(value).length > 0) { + await this.fs.writeFile(path, `${JSON.stringify(value, null, 2)}\n`); + } else if (await this.fs.fileExists(path)) { + await this.fs.deleteFile(path); + } + } +} diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 2a017b6dc..5f72483c1 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -64,8 +64,8 @@ export interface NativeActivation { * shell. `clean` reads this declaration alone and invents no path for a tool that omits * it, and never removes without `realpath` containment against this root. */ pluginCacheDir?: (homedir: string) => string; - /** This tool's own user-scope settings file, never written by aidd and read by a - * diagnostic alone. Declared only for a tool that also declares `NativeActivation`. */ + /** This tool's own user-scope settings file, read by a diagnostic; aidd writes only the main + * session's model there. Declared only for a tool that also declares `NativeActivation`. */ userSettingsPath?: (homedir: string, env: EnvironmentReader) => string; } diff --git a/cli/src/contexts/tools/domain/formats/agent-models.json b/cli/src/contexts/tools/domain/formats/agent-models.json new file mode 100644 index 000000000..2b1ea9571 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-models.json @@ -0,0 +1,58 @@ +{ + "tiers": { + "smart": "Judgment a fresh context must get right on its own: reviewing finished work.", + "standard": "The main session: frames the request, plans, delegates and accepts the result.", + "light": "Bounded work with a precise brief: exploring, executing one task, validating against criteria." + }, + "main": "standard", + "roles": { + "explorer": "light", + "executor": "light", + "validator": "light", + "reviewer": "smart" + }, + "providers": { + "openai": { + "smart": { "model": "gpt-6.1-sol", "effort": "high" }, + "standard": { "model": "gpt-6.1-terra", "effort": "medium" }, + "light": { "model": "gpt-6-luna", "effort": "medium" } + }, + "anthropic": { + "smart": { + "model": "claude-opus-5-5", + "effort": "high", + "nativeModels": { "copilot": "claude-opus-5.5" } + }, + "standard": { + "model": "claude-sonnet-5-5", + "effort": "medium", + "nativeModels": { "copilot": "claude-sonnet-5.5" } + }, + "light": { + "model": "claude-haiku-5-5", + "effort": "medium", + "nativeModels": { "copilot": "claude-haiku-5.5" } + } + }, + "mistral": { + "smart": { "model": "mistral-large-4", "effort": "high" }, + "standard": { "model": "mistral-large-4", "effort": "medium" }, + "light": { "model": "mistral-small-latest", "effort": "high" } + } + }, + "hosts": { + "claude": { + "providers": ["anthropic"], + "main": { "file": "settings.json", "effortKey": "effortLevel" } + }, + "codex": { "providers": ["openai"] }, + "copilot": { "providers": ["openai", "anthropic"] }, + "opencode": { + "providers": ["openai", "anthropic", "mistral"], + "providerPrefix": true, + "main": { "file": "opencode.json" } + }, + "cursor": { "providers": ["anthropic"] }, + "kilo": { "providers": ["openai", "anthropic", "mistral"], "providerPrefix": true } + } +} diff --git a/cli/src/contexts/tools/domain/formats/agent-models.ts b/cli/src/contexts/tools/domain/formats/agent-models.ts new file mode 100644 index 000000000..f50e87196 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-models.ts @@ -0,0 +1,129 @@ +import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; +import type { ToolBuildContract } from "../build-contract.js"; +import models from "./agent-models.json" with { type: "json" }; + +/** How much model a piece of work needs, from `smart` down to `light`; `agent-models.json` + * describes each and says which one the main session and each agent role run on. */ +export type ModelTier = "smart" | "standard" | "light"; + +export type AgentProvider = keyof typeof models.providers; +export type AgentHost = keyof typeof models.hosts; + +/** One tier at one provider. `nativeModels` holds the id a host spells differently. */ +export interface ModelProfile { + readonly model: string; + readonly effort: string; + readonly nativeModels?: Readonly>>; +} + +/** The user settings file where a host reads its main-session model. */ +export interface MainSettings { + /** Its JSON file name, read from the host's machine-wide directory where the host declares + * no user settings path of its own. */ + readonly file: string; + /** The host's key for the effort, absent where its settings take none. */ + readonly effortKey?: string; +} + +export interface HostModels { + readonly providers: readonly AgentProvider[]; + /** The host names a model `/`. */ + readonly providerPrefix?: boolean; + readonly main?: MainSettings; +} + +export interface AgentModels { + readonly tiers: Readonly>; + /** The tier the main session runs on. */ + readonly main: ModelTier; + /** Each shipped agent's tier, by its file name. */ + readonly roles: Readonly>; + readonly providers: Readonly>>>; + readonly hosts: Readonly>; +} + +/** JSON imports widen every string, so the tier names are checked by the unit test that reads + * this table, not by the compiler. */ +export const AGENT_MODELS = models as AgentModels; + +export function agentProviders(): readonly AgentProvider[] { + return Object.keys(AGENT_MODELS.providers) as AgentProvider[]; +} + +export function agentHostIds(): readonly AgentHost[] { + return Object.keys(AGENT_MODELS.hosts) as AgentHost[]; +} + +export function isAgentHost(id: string): id is AgentHost { + return Object.hasOwn(AGENT_MODELS.hosts, id); +} + +/** The provider, once the host is known to run it. */ +export function supportedProvider(host: string, provider: string): AgentProvider { + if (!isAgentHost(host)) throw new Error(`No agent provider is supported for "${host}".`); + const providers: readonly string[] = AGENT_MODELS.hosts[host].providers; + if (!providers.includes(provider)) { + throw new Error( + `Provider "${provider}" is not supported for "${host}". Use: ${providers.join(", ")}.` + ); + } + return provider as AgentProvider; +} + +/** A tier's model and effort at the provider, in the host's own model ids. */ +export function tierModel( + host: AgentHost, + provider: AgentProvider, + tier: ModelTier +): { model: string; effort: string } { + const profile = AGENT_MODELS.providers[provider][tier]; + const prefix = AGENT_MODELS.hosts[host].providerPrefix === true ? `${provider}/` : ""; + return { + model: `${prefix}${profile.nativeModels?.[host] ?? profile.model}`, + effort: profile.effort, + }; +} + +/** The agent roles that run on each tier at the provider, in the host's own ids, highest tier + * first. */ +export function roleModels( + host: AgentHost, + provider: AgentProvider +): readonly { + readonly roles: readonly string[]; + readonly model: string; + readonly effort: string; +}[] { + const tiers = Object.keys(AGENT_MODELS.tiers) as ModelTier[]; + return tiers.flatMap((tier) => { + const roles = Object.keys(AGENT_MODELS.roles).filter( + (role) => AGENT_MODELS.roles[role] === tier + ); + return roles.length === 0 ? [] : [{ roles, ...tierModel(host, provider, tier) }]; + }); +} + +/** Gives every agent whose role `agent-models.json` lists its tier's model and effort at the + * provider, before the host writes it. A model the agent declares itself is kept. */ +export function withAgentModels( + contract: ToolBuildContract, + host: string, + provider: string +): ToolBuildContract { + const chosen = supportedProvider(host, provider); + const target = host as AgentHost; + const agents = contract.artifacts.agents; + if (!agents.supported) return contract; + const write = agents.transform ?? ((content: string) => content); + const transform = (content: string, plugin: string, fileName: string): string => { + const { frontmatter, body } = parseFrontmatter(content); + const role = fileName.replace(/\.md$/, ""); + const tier = Object.hasOwn(AGENT_MODELS.roles, role) ? AGENT_MODELS.roles[role] : undefined; + if (tier === undefined || frontmatter.model !== undefined) { + return write(content, plugin, fileName); + } + const modelled = { ...frontmatter, ...tierModel(target, chosen, tier) }; + return write(serializeFrontmatter(modelled, body), plugin, fileName); + }; + return { ...contract, artifacts: { ...contract.artifacts, agents: { ...agents, transform } } }; +} diff --git a/cli/src/contexts/tools/domain/formats/main-model-config.ts b/cli/src/contexts/tools/domain/formats/main-model-config.ts new file mode 100644 index 000000000..482a4b1d9 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/main-model-config.ts @@ -0,0 +1,91 @@ +import { stripJsonComments } from "../../../../kernel/reading/jsonc.js"; +import { AGENT_MODELS, type AgentHost, type AgentProvider, tierModel } from "./agent-models.js"; + +/** Main-session settings AIDD writes, keyed by the host's native config key. */ +export type MainModelValues = Record; + +/** What AIDD wrote, where, and what each key held before, so removal restores it. */ +export interface MainModelRecord { + /** The settings file written, so removal restores that file whatever the environment says. */ + path: string; + written: MainModelValues; + previous: Record; + /** The settings file did not exist before AIDD wrote it. */ + created: boolean; +} + +/** The provider's model for the main session's tier, under the host's own keys; effort only + * where the host has a settings key for it. Empty for a host with no file-based main model. */ +export function mainModelValues(host: AgentHost, provider: AgentProvider): MainModelValues { + const settings = AGENT_MODELS.hosts[host].main; + if (settings === undefined) return {}; + const { model, effort } = tierModel(host, provider, AGENT_MODELS.main); + return settings.effortKey === undefined ? { model } : { model, [settings.effortKey]: effort }; +} + +/** + * Sets the main model in `path`, recording what each key held before. A key the person changed + * since AIDD wrote it is theirs again, so its current value becomes the one to restore. Refuses + * a file holding comments, which rewriting would drop, and a key holding no string. + */ +export function planMainModelInstall( + path: string, + content: string | null, + values: MainModelValues, + record: MainModelRecord | undefined +): { content: string; record: MainModelRecord } { + const settings = readSettings(content); + const previous: Record = {}; + for (const key of Object.keys(values)) { + const value = settings[key]; + if (value !== undefined && typeof value !== "string") + throw new Error(`"${key}" is not a string`); + const ours = record !== undefined && Object.hasOwn(record.written, key); + previous[key] = + ours && value === record.written[key] ? (record.previous[key] ?? null) : (value ?? null); + } + return { + content: serialize({ ...settings, ...values }), + record: { + path, + written: values, + previous, + created: content === null || (record?.created ?? false), + }, + }; +} + +/** Restores the keys AIDD's values still hold; leaves a key the person changed as it is. + * `null` content means the file is to be deleted. Throws on a file it cannot read, so the + * record survives for a later run. */ +export function planMainModelRemoval( + content: string | null, + record: MainModelRecord +): string | null { + if (content === null) return null; + const settings = readSettings(content); + let changed = false; + for (const [key, value] of Object.entries(record.written)) { + if (settings[key] !== value) continue; + const previous = record.previous[key] ?? null; + if (previous === null) delete settings[key]; + else settings[key] = previous; + changed = true; + } + if (!changed) return content; + return record.created && Object.keys(settings).length === 0 ? null : serialize(settings); +} + +function readSettings(content: string | null): Record { + if (content === null || content.trim() === "") return {}; + if (stripJsonComments(content) !== content) + throw new Error("it holds comments or trailing commas, which rewriting it would drop"); + const parsed: unknown = JSON.parse(content); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + throw new Error("it holds no JSON object"); + return parsed as Record; +} + +function serialize(settings: Record): string { + return `${JSON.stringify(settings, null, 2)}\n`; +} diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts index 7fa94ba68..fe2e43d39 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -133,8 +133,10 @@ export const claude: AiTool join(h, ".claude", "plugins", "cache"), // Where a `--scope user` install or marketplace add lands, measured against the real - // binary. Never written by aidd; named here for a diagnostic alone. - userSettingsPath: (h) => join(h, ".claude", "settings.json"), + // binary, under `CLAUDE_CONFIG_DIR` when set. aidd writes the main session's model + // there, nothing else. + userSettingsPath: (h, env) => + join(env("CLAUDE_CONFIG_DIR") || join(h, ".claude"), "settings.json"), }, marketplaceSettings: { settingsPath: ".claude/settings.json", diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index 997773fcc..fa02e04bb 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -164,8 +164,8 @@ export function buildContractFor( return config.buildContracts?.[mode]; } -/** A tool's own user-scope settings file, absolute under `homedir` — never written by aidd, - * only named by a diagnostic. Empty for a tool whose profile declares no +/** A tool's own user-scope settings file — aidd writes only the main session's model there. + * Empty for a tool whose profile declares no * `NativeActivation.userSettingsPath`. */ export function userMachineLocalFilesOf( toolId: ToolId, diff --git a/cli/src/presentation/commands/clean.ts b/cli/src/presentation/commands/clean.ts index 91e608ad4..83f5129c0 100644 --- a/cli/src/presentation/commands/clean.ts +++ b/cli/src/presentation/commands/clean.ts @@ -43,6 +43,20 @@ async function runUserScopeClean( }); printUserScopeCleanOutcome(output, result, interactive); + + // The agents' provider and the main sessions' models are machine-wide choices too, undone + // exactly when the clean itself ran: --force, or a confirmed prompt. + const models = await deps.setAgentModelUseCase.reset({ dryRun: result.dryRun }); + const verb = result.dryRun ? "would be restored" : "restored"; + for (const { host, path } of models.changed) + output.info(`${host} main session: ${verb} in ${path}`); + for (const { host, path, reason } of models.skipped) { + output.warn(`${host} main session left as it is in ${path}: ${reason}.`); + } + if (models.providers.length > 0) { + const forgotten = result.dryRun ? "would be forgotten" : "forgotten"; + output.info(`Agents' provider for ${models.providers.join(", ")}: ${forgotten}.`); + } } export function registerCleanCommand(program: Command): void { diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts index bcd93efc1..f365c0c36 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -3,6 +3,17 @@ import { type Command, Option } from "commander"; import { MarketplaceSourceMode } from "../../contexts/distribution/domain/marketplace-source-mode.js"; import { SetupUseCase } from "../../contexts/framework/application/setup-use-case.js"; import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; +import { + AGENT_MODELS, + type AgentHost, + type AgentProvider, + agentHostIds, + agentProviders, + isAgentHost, + roleModels, + supportedProvider, + tierModel, +} from "../../contexts/tools/domain/formats/agent-models.js"; import { assertToolIdsMatchCategory, copiesPluginFilesGlobally, @@ -25,6 +36,59 @@ import type { CLIOutput } from "../output.js"; import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; import { reportSyncActivation } from "./sync-native-activation.js"; +/** Says, per host, what the main session and each agent now run on, where the main session's + * model was written, which plugins were updated, and what other projects still need. */ +function reportAgentModels( + output: CLIOutput, + provider: AgentProvider | undefined, + hosts: readonly AgentHost[], + main: { + readonly reinstalled: readonly string[]; + readonly changed: readonly { readonly host: AgentHost; readonly path: string }[]; + readonly skipped: readonly { + readonly host: AgentHost; + readonly path: string; + readonly reason: string; + }[]; + } +): void { + if (provider === undefined) { + output.success(`${hosts.join(", ")}: the agents run on the IDE's model again.`); + for (const { host, path } of main.changed) + output.info(` ${host} main session: restored in ${path}`); + } else { + output.success(`Models on ${provider}, for every project on this machine:`); + for (const host of hosts) { + const skipped = main.skipped.some((entry) => entry.host === host); + const written = main.changed.findLast((entry) => entry.host === host); + const { model, effort } = tierModel(host, provider, AGENT_MODELS.main); + const effortShown = + AGENT_MODELS.hosts[host].main?.effortKey === undefined ? "" : ` (${effort})`; + if (!skipped) { + output.info( + written === undefined + ? ` ${host} main session: not managed by aidd, still the IDE's choice` + : ` ${host} main session: ${model}${effortShown}, written in ${written.path}` + ); + } + for (const group of roleModels(host, provider)) { + output.info(` ${host} ${group.roles.join(", ")}: ${group.model} (${group.effort})`); + } + } + } + for (const { host, path, reason } of main.skipped) { + output.warn(`${host} main session left as it is in ${path}: ${reason}.`); + } + output.info( + main.reinstalled.length > 0 + ? `Agents updated: ${main.reinstalled.join(", ")}.` + : "Agents installed later get these models too." + ); + output.info( + `Other projects follow at their next install; to update the agents they already have, run \`aidd setup --ide ${hosts.join(",")} --provider ${provider ?? "none"}\` there.` + ); +} + interface SetupCmdOptions { source?: "remote" | "local"; path?: string; @@ -36,6 +100,7 @@ interface SetupCmdOptions { defaultMarketplace?: boolean; scope?: string; global?: boolean; + provider?: string; } export function parseSourceFlag( @@ -143,6 +208,10 @@ export function registerSetupCommand(program: Command): void { "requirements below" ) .addOption(new Option("--scope ", "Former spelling: user means --global").hideHelp()) + .option( + "--provider ", + `Run each tool's main session and agents on this provider's model for their role (${agentProviders().join(", ")}), or none for the IDE's model` + ) .addHelpText("after", globalSetupHelp) .action(async (cmdOptions: SetupCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); @@ -160,7 +229,8 @@ export function registerSetupCommand(program: Command): void { cmdOptions.ide || cmdOptions.plugins || cmdOptions.yes || - cmdOptions.global + cmdOptions.global || + cmdOptions.provider ); const interactive = process.stdout.isTTY && !hasScriptingFlags; @@ -184,6 +254,25 @@ export function registerSetupCommand(program: Command): void { scope, }); + // One provider per run: claude and codex each run one only, so pairing them is refused + // here, naming the host, before anything is built or written. + const agentHosts = toolIds.aiTools.filter(isAgentHost); + const provider = cmdOptions.provider === "none" ? undefined : cmdOptions.provider; + const chosen = agentProviders().find((known) => known === provider); + if (provider !== undefined && chosen === undefined) { + throw new Error( + `Unknown provider "${provider}". Use: ${agentProviders().join(", ")}, or none.` + ); + } + if (cmdOptions.provider !== undefined && agentHosts.length === 0) { + throw new Error( + `--provider needs --ide with a tool that runs the framework's agents: ${agentHostIds().join(", ")}.` + ); + } + if (provider !== undefined) { + for (const host of agentHosts) supportedProvider(host, provider); + } + if (interactive) printWelcomeBanner(output); const deps = await createDeps(projectRoot, { verbose }, output); @@ -205,6 +294,17 @@ export function registerSetupCommand(program: Command): void { printDetectedContext(output, result.context.describe()); } printSetupOutcome(output, result, verbose); + // Once the install has succeeded: the provider and the main session's model hold for + // every project on the machine, so a failed install leaves both as they were. + if (cmdOptions.provider !== undefined) { + const models = await deps.setAgentModelUseCase.execute({ + projectRoot, + hosts: agentHosts, + provider, + scope: scope ?? "project", + }); + reportAgentModels(output, chosen, agentHosts, models); + } if (interactive) printNextSteps(output, result.install.results.length > 0); reportSyncActivation(output, result.activation); } catch (error) { diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 47bfd5441..e117eff39 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -11,6 +11,7 @@ import { MarketplaceAddUseCase } from "../../contexts/distribution/application/m import type { MarketplaceListUseCase } from "../../contexts/distribution/application/marketplace-list-use-case.js"; import type { MarketplaceRefreshUseCase } from "../../contexts/distribution/application/marketplace-refresh-use-case.js"; import type { MarketplaceRegisterFrameworkUseCase } from "../../contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { SetAgentModelUseCase } from "../../contexts/framework/application/agents/set-agent-model-use-case.js"; import { CleanUserScopeUseCase } from "../../contexts/framework/application/clean/clean-user-scope-use-case.js"; import { CleanUseCase } from "../../contexts/framework/application/clean-use-case.js"; import { DoctorLayoutUseCase } from "../../contexts/framework/application/doctor/doctor-layout-use-case.js"; @@ -59,6 +60,7 @@ import { SetupMarketplaceSourceUseCase } from "../../contexts/framework/applicat import { SetupToolsUseCase } from "../../contexts/framework/application/setup/setup-tools-use-case.js"; import { DetectPluginDriftUseCase } from "../../contexts/framework/application/shared/detect-plugin-drift-use-case.js"; import { + type AgentProviderFor, EnsureBuiltMarketplaceUseCase, type FrameworkBuildFor, } from "../../contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; @@ -70,12 +72,14 @@ import { UninstallUseCase } from "../../contexts/framework/application/uninstall import type { Environment } from "../../contexts/framework/domain/ports/environment.js"; import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../contexts/framework/domain/ports/user-source-references.js"; +import { AgentModelStoreAdapter } from "../../contexts/framework/infrastructure/agent-model-store-adapter.js"; import { EnvironmentAdapter } from "../../contexts/framework/infrastructure/environment-adapter.js"; import { ManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/manifest-repository-adapter.js"; import { PluginDistributionReaderAdapter } from "../../contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { RecipeFilesAdapter } from "../../contexts/framework/infrastructure/recipe-files-adapter.js"; import { UserManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/user-manifest-repository-adapter.js"; import { UserSourceReferencesAdapter } from "../../contexts/framework/infrastructure/user-source-references-adapter.js"; +import { isAgentHost } from "../../contexts/tools/domain/formats/agent-models.js"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import type { NativeMarketplaceSourceReader } from "../../contexts/tools/domain/ports/native-marketplace-source-reader.js"; import { codexMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/codex/native-marketplace-source.js"; @@ -145,6 +149,7 @@ interface Deps { pluginRemoveUseCase: PluginRemoveUseCase; pluginListUseCase: PluginListUseCase; pluginUpdateUseCase: PluginUpdateUseCase; + setAgentModelUseCase: SetAgentModelUseCase; userPluginUpdateUseCase: UserPluginUpdateUseCase; marketplaceAddUseCase: MarketplaceAddUseCase; marketplaceListUseCase: MarketplaceListUseCase; @@ -330,17 +335,21 @@ export async function createDeps( info: (message) => logger.debug(message), warn: (message) => logger.debug(message), }; - const frameworkBuildFor: FrameworkBuildFor = (target, mode, outDir) => + const frameworkBuildFor: FrameworkBuildFor = (target, mode, outDir, provider) => createFrameworkBuildUseCase( { fs, assetProvider, logger: cacheBuildLogger }, - { target, mode, outDir, force: true } + { target, mode, outDir, force: true, ...(provider === undefined ? {} : { provider }) } ); + const agentModelStore = new AgentModelStoreAdapter(fs, userConfigDir); + const agentProviderFor: AgentProviderFor = async (target) => + isAgentHost(target) ? (await agentModelStore.loadProviders())[target] : undefined; const ensureBuiltMarketplaceUseCase = new EnsureBuiltMarketplaceUseCase( fs, resolveMarketplaceUseCase, frameworkBuildFor, currentVersionProvider, - userConfigDir + userConfigDir, + agentProviderFor ); const marketplaceSyncSettingsUseCase = new MarketplaceSyncSettingsUseCase( fs, @@ -530,6 +539,17 @@ export async function createDeps( logger, nativeHostRegistrationGate ); + const setAgentModelUseCase = new SetAgentModelUseCase( + fs, + agentModelStore, + manifestRepo, + userManifestRepo, + pluginUpdateUseCase, + userPluginUpdateUseCase, + marketplaceSyncSettingsUseCase, + homedir, + (name) => environment.get(name) + ); const restoreUseCase = new RestoreUseCase( fs, manifestRepo, @@ -615,6 +635,7 @@ export async function createDeps( pluginRemoveUseCase, pluginListUseCase, pluginUpdateUseCase, + setAgentModelUseCase, userPluginUpdateUseCase, marketplaceAddUseCase, marketplaceListUseCase, diff --git a/cli/src/runtime/wiring/translate.ts b/cli/src/runtime/wiring/translate.ts index 5c7ee0c41..f7d16e327 100644 --- a/cli/src/runtime/wiring/translate.ts +++ b/cli/src/runtime/wiring/translate.ts @@ -9,6 +9,7 @@ import "../../contexts/tools/domain/profiles/kilo/profile.js"; import "../../contexts/tools/domain/profiles/opencode/profile.js"; import "../../contexts/tools/domain/profiles/vscode/profile.js"; import type { ToolBuildContract } from "../../contexts/tools/domain/build-contract.js"; +import { withAgentModels } from "../../contexts/tools/domain/formats/agent-models.js"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import { buildContractFor } from "../../contexts/tools/domain/registry.js"; import { FlatBuildStrategy } from "../../contexts/translate/application/strategies/flat-build-strategy.js"; @@ -32,6 +33,8 @@ export interface FrameworkBuildContext { readonly mode: string; readonly outDir: string; readonly force: boolean; + /** Gives each agent its role's model at this provider; absent, agents inherit the session's. */ + readonly provider?: string; } type FrameworkBuildFactory = ( @@ -68,12 +71,16 @@ function frameworkBuildFactoryFor( buildContract: () => ToolBuildContract, mode: "marketplace" | "flat" ): FrameworkBuildFactory { + const contractFor = (ctx: FrameworkBuildContext): ToolBuildContract => + ctx.provider === undefined + ? buildContract() + : withAgentModels(buildContract(), ctx.target, ctx.provider); if (mode === "marketplace") { return (deps, ctx) => buildFrameworkUseCase( deps, (d, av) => - new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, buildContract(), ctx.force) + new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, contractFor(ctx), ctx.force) ); } return (deps, ctx) => @@ -84,7 +91,7 @@ function frameworkBuildFactoryFor( d.fs, av, d.assetProvider, - buildContract(), + contractFor(ctx), ctx.force, ctx.outDir, isDirectory, diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts index 31661c70d..e39fc0e3c 100644 --- a/cli/tests/architecture/context-boundary.arch.test.ts +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -47,6 +47,11 @@ const PUBLIC_MODULES: Readonly> = { // The variable each tool expands to an installed plugin's directory: a tool declares it, // translate substitutes it, the diagnostic looks for it in what was installed. "src/contexts/tools/domain/formats/plugin-root-token.ts", + // Which model each agent role and the main session run on, per host and provider: the + // setter records the choice, the install build applies it, the CLI lists the names. + "src/contexts/tools/domain/formats/agent-models.ts", + // How a host's own settings file carries the main session's model, set and restored. + "src/contexts/tools/domain/formats/main-model-config.ts", ], translate: [ // the canonical shapes framework produces and translate consumes diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 5c901470e..5dd8941aa 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -89,9 +89,19 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] injected: 5, }, { path: "src/contexts/framework/application/shared/apply-plugin-files-use-case.ts", injected: 5 }, + // The sixth is the provider chosen for a target's agents: it changes the built agents, so + // the cache that keeps a build fresh must read it. { path: "src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts", - injected: 5, + injected: 6, + }, + // Setting a model touches what each scope installed: the provider store, both manifests, both + // plugin updates, and native activation, since a host that installs plugins itself only sees a + // rebuild when driven; plus the files, home and environment that locate the main session's + // settings. + { + path: "src/contexts/framework/application/agents/set-agent-model-use-case.ts", + injected: 9, }, { path: "src/contexts/translate/application/strategies/marketplace-build-strategy.ts", diff --git a/cli/tests/contexts/framework/application/agents/set-agent-model-use-case.integration.test.ts b/cli/tests/contexts/framework/application/agents/set-agent-model-use-case.integration.test.ts new file mode 100644 index 000000000..058a34701 --- /dev/null +++ b/cli/tests/contexts/framework/application/agents/set-agent-model-use-case.integration.test.ts @@ -0,0 +1,406 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { SetAgentModelUseCase } from "../../../../../src/contexts/framework/application/agents/set-agent-model-use-case.js"; +import type { MarketplaceSyncSettingsOptions } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { PluginUpdateOptions } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { AgentModelStoreAdapter } from "../../../../../src/contexts/framework/infrastructure/agent-model-store-adapter.js"; +import { FaultingFileAdapter } from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const PROJECT = "/p"; +const HOME = "/home/me"; +const CONFIG = "/home/me/.config/aidd"; +const CLAUDE_SETTINGS = join(HOME, ".claude", "settings.json"); +const OPENCODE_SETTINGS = join(HOME, ".config", "opencode", "opencode.json"); + +function plugin(scope: "project" | "user"): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name: "aidd-orchestrator", + source: { kind: "local", path: "./orchestrator" }, + version: "1.0.0", + strict: false, + files: {}, + scope, + }); +} + +function setup( + seed?: (manifest: Manifest) => void, + userManifest: Manifest | null = null, + fs: InMemoryFileAdapter = new InMemoryFileAdapter(), + env: (name: string) => string | undefined = () => undefined, + updateFails = false +) { + const manifest = Manifest.create(); + seed?.(manifest); + const updates: PluginUpdateOptions[] = []; + const userUpdates: PluginUpdateOptions[] = []; + const activations: MarketplaceSyncSettingsOptions[] = []; + const useCase = new SetAgentModelUseCase( + fs, + new AgentModelStoreAdapter(fs, () => CONFIG), + new InMemoryManifestRepository(manifest, PROJECT), + new InMemoryManifestRepository(userManifest, CONFIG), + { + execute: async (options) => { + if (updateFails) throw new Error("build failed"); + updates.push(options); + return ["aidd-orchestrator"]; + }, + }, + { + execute: async (options) => { + userUpdates.push(options); + return []; + }, + }, + { + execute: async (options) => { + activations.push(options); + return { activated: [], binaryMissing: [], warnings: [], errors: [] }; + }, + }, + () => HOME, + env + ); + return { fs, useCase, updates, userUpdates, activations }; +} + +describe("SetAgentModelUseCase", () => { + it("records the provider machine-wide, re-installs this project's plugins, then sets the main session", async () => { + const { fs, useCase, updates, activations } = setup((manifest) => { + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin("opencode", plugin("project")); + }); + + const result = await useCase.execute({ + projectRoot: PROJECT, + hosts: ["opencode"], + provider: "mistral", + scope: "project", + }); + + expect(fs.getFile(`${CONFIG}/agent-models.json`)).toBe('{\n "opencode": "mistral"\n}\n'); + expect(updates).toStrictEqual([{ toolIds: ["opencode"], projectRoot: PROJECT, rebuild: true }]); + expect(activations).toStrictEqual([{ projectRoot: PROJECT, toolIds: ["opencode"] }]); + expect(result.reinstalled).toStrictEqual(["aidd-orchestrator"]); + }); + + it("sets the main session on its tier's model, and restores what it replaced once unset", async () => { + const { fs, useCase } = setup(); + fs.setFile(CLAUDE_SETTINGS, '{\n "model": "mine"\n}\n'); + + const set = await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude"], + provider: "anthropic", + }); + expect(set).toStrictEqual({ + reinstalled: [], + changed: [{ host: "claude", path: CLAUDE_SETTINGS }], + skipped: [], + }); + expect(fs.getFile(CLAUDE_SETTINGS)).toBe( + '{\n "model": "claude-sonnet-5-5",\n "effortLevel": "medium"\n}\n' + ); + + const unset = await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude"], + }); + expect(unset.changed).toStrictEqual([{ host: "claude", path: CLAUDE_SETTINGS }]); + expect(fs.getFile(CLAUDE_SETTINGS)).toBe('{\n "model": "mine"\n}\n'); + expect(fs.has(`${CONFIG}/main-model.json`)).toBe(false); + }); + + it("writes the main model in the host's user settings, never a project file", async () => { + const { fs, useCase } = setup(); + + const result = await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude", "opencode", "cursor"], + provider: "anthropic", + }); + + expect(fs.getFile(OPENCODE_SETTINGS)).toBe('{\n "model": "anthropic/claude-sonnet-5-5"\n}\n'); + expect(result.changed).toStrictEqual([ + { host: "claude", path: CLAUDE_SETTINGS }, + { host: "opencode", path: OPENCODE_SETTINGS }, + ]); + expect(fs.listAll().filter((path) => path.startsWith(`${PROJECT}/`))).toStrictEqual([]); + }); + + it("follows CLAUDE_CONFIG_DIR to the settings file Claude Code reads", async () => { + const custom = join(HOME, "claude-config"); + const { fs, useCase } = setup(undefined, null, undefined, (name) => + name === "CLAUDE_CONFIG_DIR" ? custom : undefined + ); + + await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude"], + provider: "anthropic", + }); + + expect(fs.has(join(custom, "settings.json"))).toBe(true); + expect(fs.has(CLAUDE_SETTINGS)).toBe(false); + }); + + it("writes where Claude Code reads now, and restores the file it wrote before", async () => { + let configDir: string | undefined; + const { fs, useCase } = setup(undefined, null, undefined, (name) => + name === "CLAUDE_CONFIG_DIR" ? configDir : undefined + ); + const base = { projectRoot: PROJECT, scope: "project" as const, hosts: ["claude"] as const }; + fs.setFile(CLAUDE_SETTINGS, '{\n "model": "opus"\n}\n'); + + await useCase.execute({ ...base, provider: "anthropic" }); + configDir = join(HOME, "claude-config"); + await useCase.execute({ ...base }); + + expect(fs.getFile(CLAUDE_SETTINGS)).toBe('{\n "model": "opus"\n}\n'); + expect(fs.has(join(HOME, "claude-config", "settings.json"))).toBe(false); + }); + + it("writes OpenCode's JSONC config when it is the only one there", async () => { + const { fs, useCase } = setup(); + fs.setFile(`${OPENCODE_SETTINGS}c`, '{\n "theme": "dark"\n}\n'); + + const result = await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["opencode"], + provider: "openai", + }); + + expect(result.changed).toStrictEqual([{ host: "opencode", path: `${OPENCODE_SETTINGS}c` }]); + expect(fs.has(OPENCODE_SETTINGS)).toBe(false); + }); + + it("skips a settings file holding comments, writing nothing, and goes on with the others", async () => { + const { fs, useCase } = setup(); + const commented = '{\n // mine\n "model": "opus"\n}\n'; + fs.setFile(CLAUDE_SETTINGS, commented); + + const result = await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude", "opencode"], + provider: "anthropic", + }); + + expect(fs.getFile(CLAUDE_SETTINGS)).toBe(commented); + expect(result.skipped).toStrictEqual([ + { + host: "claude", + path: CLAUDE_SETTINGS, + reason: "it holds comments or trailing commas, which rewriting it would drop", + }, + ]); + expect(result.changed).toStrictEqual([{ host: "opencode", path: OPENCODE_SETTINGS }]); + }); + + it("keeps the record of a file it cannot read, so a later run can still restore it", async () => { + const { fs, useCase } = setup(); + await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude"], + provider: "anthropic", + }); + fs.setFile(CLAUDE_SETTINGS, "// broken"); + + const result = await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude"], + }); + + expect(result.skipped.map(({ host }) => host)).toStrictEqual(["claude"]); + expect(JSON.parse(fs.getFile(`${CONFIG}/main-model.json`) ?? "{}")).toHaveProperty("claude"); + }); + + it("records what a settings file held before writing it, so a failed run can still restore it", async () => { + const fs = new FaultingFileAdapter(); + const { useCase } = setup(undefined, null, fs); + fs.setFile(CLAUDE_SETTINGS, '{\n "model": "mine"\n}\n'); + fs.failOn("writeFile", CLAUDE_SETTINGS, new Error("disk full")); + + await expect( + useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude"], + provider: "anthropic", + }) + ).rejects.toThrow("disk full"); + + expect(JSON.parse(fs.getFile(`${CONFIG}/main-model.json`) ?? "{}").claude.previous).toEqual({ + model: "mine", + effortLevel: null, + }); + }); + + it("moves a main model already set to the new provider", async () => { + const { fs, useCase } = setup(); + + await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["opencode"], + provider: "mistral", + }); + await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["opencode"], + provider: "openai", + }); + + expect(fs.getFile(OPENCODE_SETTINGS)).toBe('{\n "model": "openai/gpt-6.1-terra"\n}\n'); + }); + + it("refreshes this project globally too, and adds the machine-scope registrations", async () => { + const userManifest = Manifest.create(); + userManifest.addTool("claude", "1.0.0", []); + const { useCase, updates, activations } = setup((manifest) => { + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin("claude", plugin("project")); + }, userManifest); + + await useCase.execute({ + projectRoot: PROJECT, + hosts: ["claude"], + provider: "anthropic", + scope: "user", + }); + + expect(updates).toStrictEqual([{ toolIds: ["claude"], projectRoot: PROJECT, rebuild: true }]); + expect(activations.map(({ toolIds, scope }) => ({ toolIds, scope }))).toStrictEqual([ + { toolIds: ["claude"], scope: undefined }, + { toolIds: ["claude"], scope: "user" }, + ]); + }); + + it("rewrites a plugin installed in the user's home whatever the scope", async () => { + const userManifest = Manifest.create(); + userManifest.addTool("cursor", "1.0.0", []); + userManifest.addPlugin("cursor", plugin("user")); + const { useCase, userUpdates } = setup(undefined, userManifest); + + await useCase.execute({ + projectRoot: PROJECT, + hosts: ["cursor"], + provider: "anthropic", + scope: "project", + }); + + expect(userUpdates).toStrictEqual([ + { toolIds: ["cursor"], projectRoot: PROJECT, scope: "user", rebuild: true }, + ]); + }); + + it("resets the machine: restores every main session it changed and forgets the providers", async () => { + const { fs, useCase } = setup(); + fs.setFile(CLAUDE_SETTINGS, '{\n "model": "opus"\n}\n'); + const base = { projectRoot: PROJECT, scope: "project" as const }; + await useCase.execute({ ...base, hosts: ["claude", "opencode"], provider: "anthropic" }); + + const preview = await useCase.reset({ dryRun: true }); + expect(preview.providers).toStrictEqual(["claude", "opencode"]); + expect(preview.changed.map(({ host }) => host)).toStrictEqual(["claude", "opencode"]); + expect(fs.getFile(CLAUDE_SETTINGS)).toContain("claude-sonnet-5-5"); + + await useCase.reset({ dryRun: false }); + expect(fs.getFile(CLAUDE_SETTINGS)).toBe('{\n "model": "opus"\n}\n'); + expect(fs.has(OPENCODE_SETTINGS)).toBe(false); + expect(fs.has(`${CONFIG}/agent-models.json`)).toBe(false); + expect(fs.has(`${CONFIG}/main-model.json`)).toBe(false); + }); + + it("restores the file it wrote before when the host now reads another one", async () => { + let configDir: string | undefined; + const { fs, useCase } = setup(undefined, null, undefined, (name) => + name === "CLAUDE_CONFIG_DIR" ? configDir : undefined + ); + const base = { projectRoot: PROJECT, scope: "project" as const, hosts: ["claude"] as const }; + fs.setFile(CLAUDE_SETTINGS, '{\n "model": "opus"\n}\n'); + await useCase.execute({ ...base, provider: "anthropic" }); + + configDir = join(HOME, "claude-config"); + const moved = await useCase.execute({ ...base, provider: "anthropic" }); + + const custom = join(HOME, "claude-config", "settings.json"); + expect(fs.getFile(CLAUDE_SETTINGS)).toBe('{\n "model": "opus"\n}\n'); + expect(fs.getFile(custom)).toContain("claude-sonnet-5-5"); + expect(moved.changed.at(-1)).toStrictEqual({ host: "claude", path: custom }); + }); + + it("puts the previous provider back when rebuilding this project's agents fails", async () => { + const { fs, useCase } = setup((manifest) => { + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin("opencode", plugin("project")); + }); + const base = { projectRoot: PROJECT, scope: "project" as const, hosts: ["opencode"] as const }; + await useCase.execute({ ...base, provider: "mistral" }); + const failing = setup( + (manifest) => { + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin("opencode", plugin("project")); + }, + null, + fs, + undefined, + true + ); + + await expect(failing.useCase.execute({ ...base, provider: "openai" })).rejects.toThrow( + "build failed" + ); + + expect(fs.getFile(`${CONFIG}/agent-models.json`)).toBe('{\n "opencode": "mistral"\n}\n'); + expect(fs.getFile(OPENCODE_SETTINGS)).toContain("mistral/mistral-large-4"); + }); + + it("forgets the provider on reset even when a settings file cannot be restored, and keeps its record", async () => { + const { fs, useCase } = setup(); + await useCase.execute({ + projectRoot: PROJECT, + scope: "project", + hosts: ["claude"], + provider: "anthropic", + }); + fs.setFile(CLAUDE_SETTINGS, '{\n // mine\n "model": "claude-sonnet-5-5"\n}\n'); + + const reset = await useCase.reset({ dryRun: false }); + + expect(reset.skipped.map(({ host }) => host)).toStrictEqual(["claude"]); + expect(fs.has(`${CONFIG}/agent-models.json`)).toBe(false); + expect(JSON.parse(fs.getFile(`${CONFIG}/main-model.json`) ?? "{}")).toHaveProperty("claude"); + }); + + it("refuses a provider one host cannot run before writing anything", async () => { + const { fs, useCase } = setup(); + + await expect( + useCase.execute({ + projectRoot: PROJECT, + hosts: ["opencode", "claude"], + provider: "mistral", + scope: "project", + }) + ).rejects.toThrow('Provider "mistral" is not supported for "claude". Use: anthropic.'); + expect(fs.listAll()).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts index 55f6023ce..1c45d5f52 100644 --- a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts @@ -145,6 +145,50 @@ describe("EnsureBuiltMarketplaceUseCase", () => { expect(fs.getFile(join(r.builtDir, ".build-version"))).toBe("5.0.0:1.0.0"); }); + it("builds a target's agents with the provider chosen for it, and records it in the sentinel", async () => { + const providers: (string | undefined)[] = []; + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + (target, mode, outDir, provider) => { + providers.push(provider); + return buildFor(target, mode, outDir); + }, + fakeVersion("5.0.0"), + () => "/user-cache", + async (target) => (target === "codex" ? "openai" : undefined) + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeRemoteMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(providers).toStrictEqual(["openai"]); + expect(fs.getFile(join(r.builtDir, ".build-version"))).toBe("5.0.0:1.0.0:openai"); + }); + + it("rebuilds a published source once its agents' provider changed", async () => { + const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")); + fs.setFile(join(builtDir, ".build-version"), "5.0.0:1.0.0"); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache", + async () => "openai" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeRemoteMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.rebuilt).toBe(true); + expect(builds).toBe(1); + }); + it("does not rebuild a published source when the sentinel matches (cliVer:catalogVer)", async () => { // resolve(): a drive-less PROJECT would seed a key the in-memory fs never looks up under, // since the code's own builtDir is resolved with a drive letter. diff --git a/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts new file mode 100644 index 000000000..f664c086a --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { ToolBuildContract } from "../../../../../src/contexts/tools/domain/build-contract.js"; +import { + AGENT_MODELS, + withAgentModels, +} from "../../../../../src/contexts/tools/domain/formats/agent-models.js"; +import { buildClaudeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { buildCodexFlatContract } from "../../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { buildCopilotFlatContract } from "../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { buildCursorFlatContract } from "../../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { buildKiloFlatContract } from "../../../../../src/contexts/tools/domain/profiles/kilo/build.js"; +import { buildOpencodeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; + +const AGENT = `--- +description: Reviews the work +--- +Review. +`; + +function write(contract: ToolBuildContract, fileName: string, content = AGENT): string { + const agents = contract.artifacts.agents; + if (!agents.supported) throw new Error("no agents"); + return agents.transform?.(content, "aidd-orchestrator", fileName) ?? content; +} + +describe("withAgentModels()", () => { + it("gives a light role the provider's light model and effort, in the host's keys", () => { + expect( + write(withAgentModels(buildCodexFlatContract(), "codex", "openai"), "explorer.md") + ).toBe(`name = "aidd-orchestrator-explorer" +description = "Reviews the work" +model = "gpt-6-luna" +model_reasoning_effort = "medium" +developer_instructions = "Review.\\n" +`); + }); + + it("gives the reviewer the smart model, prefixed by the provider where the host wants it", () => { + expect( + write(withAgentModels(buildOpencodeFlatContract(), "opencode", "mistral"), "reviewer.md") + ).toBe(`--- +description: 'Reviews the work' +model: 'mistral/mistral-large-4#high' +mode: 'subagent' +--- +Review. +`); + }); + + it("gives a Cursor agent the model of its role", () => { + expect( + write(withAgentModels(buildCursorFlatContract(), "cursor", "anthropic"), "reviewer.md") + ).toContain("claude-opus-5-5"); + }); + + it("uses the host's own id for a model it names differently", () => { + expect( + write(withAgentModels(buildCopilotFlatContract(), "copilot", "anthropic"), "validator.md") + ).toBe(`--- +name: 'aidd-orchestrator-validator' +description: 'Reviews the work' +model: 'claude-haiku-5.5' +reasoningEffort: 'medium' +include-custom-instructions: true +--- +Review. +`); + }); + + it("leaves an agent whose role is not listed, or that declares its own model, as written", () => { + const contract = withAgentModels(buildClaudeFlatContract(), "claude", "anthropic"); + const plain = buildClaudeFlatContract(); + const pinned = "---\ndescription: d\nmodel: claude-opus-5-5\n---\nBody.\n"; + expect(write(contract, "planner.md")).toBe(write(plain, "planner.md")); + expect(write(contract, "explorer.md", pinned)).toBe(write(plain, "explorer.md", pinned)); + }); + + it("gives a Kilo agent a provider-prefixed model and the effort as its variant", () => { + expect( + write(withAgentModels(buildKiloFlatContract(), "kilo", "anthropic"), "explorer.md") + ).toBe(`--- +description: 'Reviews the work' +model: 'anthropic/claude-haiku-5-5' +variant: 'medium' +mode: 'subagent' +--- +Review. +`); + }); + + it("refuses a provider the host cannot run, and a host with no provider", () => { + expect(() => withAgentModels(buildCodexFlatContract(), "codex", "mistral")).toThrow( + 'Provider "mistral" is not supported for "codex". Use: openai.' + ); + expect(() => withAgentModels(buildCodexFlatContract(), "vscode", "openai")).toThrow( + 'No agent provider is supported for "vscode".' + ); + }); +}); + +describe("agent-models.json", () => { + const tiers = Object.keys(AGENT_MODELS.tiers); + + it("names three tiers, and gives the main session and every role one of them", () => { + expect(tiers).toStrictEqual(["smart", "standard", "light"]); + expect(tiers).toContain(AGENT_MODELS.main); + for (const tier of Object.values(AGENT_MODELS.roles)) expect(tiers).toContain(tier); + }); + + it("defines every tier at every provider", () => { + for (const profiles of Object.values(AGENT_MODELS.providers)) { + expect(Object.keys(profiles).sort()).toStrictEqual([...tiers].sort()); + } + }); + + it("offers each host only providers it defines", () => { + for (const host of Object.values(AGENT_MODELS.hosts)) { + for (const provider of host.providers) + expect(AGENT_MODELS.providers).toHaveProperty(provider); + } + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/main-model-config.unit.test.ts b/cli/tests/contexts/tools/domain/formats/main-model-config.unit.test.ts new file mode 100644 index 000000000..a0def03f0 --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/main-model-config.unit.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + mainModelValues, + planMainModelInstall, + planMainModelRemoval, +} from "../../../../../src/contexts/tools/domain/formats/main-model-config.js"; + +const P = "/home/me/.claude/settings.json"; + +describe("main model settings", () => { + it.each([ + ["claude", "anthropic", { model: "claude-sonnet-5-5", effortLevel: "medium" }], + // OpenCode's root `model` keeps no variant, so no effort is written. + ["opencode", "mistral", { model: "mistral/mistral-large-4" }], + ] as const)( + "resolves %s / %s to the main session's tier model, in native settings", + (host, provider, expected) => { + expect(mainModelValues(host, provider)).toStrictEqual(expected); + } + ); + + it.each([ + ["codex", "openai"], + ["copilot", "openai"], + ["cursor", "anthropic"], + ["kilo", "mistral"], + ] as const)("leaves %s without a file-based main model", (host, provider) => { + expect(mainModelValues(host, provider)).toStrictEqual({}); + }); + + it("merges JSON settings, keeping other keys, and restores the previous value", () => { + const content = '{\n "model": "opus",\n "hooks": { "a": 1 }\n}'; + const { content: next, record } = planMainModelInstall(P, content, { model: "x" }, undefined); + expect(next).toBe('{\n "model": "x",\n "hooks": {\n "a": 1\n }\n}\n'); + expect(record).toStrictEqual({ + path: P, + written: { model: "x" }, + previous: { model: "opus" }, + created: false, + }); + expect(planMainModelRemoval(next, record)).toBe( + '{\n "model": "opus",\n "hooks": {\n "a": 1\n }\n}\n' + ); + }); + + it("refuses a file holding comments, one holding no object, or a value that is not a string", () => { + expect(() => + planMainModelInstall(P, '{\n // mine\n "a": 1\n}', { model: "x" }, undefined) + ).toThrow("comments"); + expect(() => planMainModelInstall(P, "[]", { model: "x" }, undefined)).toThrow( + "no JSON object" + ); + expect(() => planMainModelInstall(P, '{ "model": 1 }', { model: "x" }, undefined)).toThrow( + '"model" is not a string' + ); + }); + + it("keeps what each key held before AIDD first wrote it", () => { + const first = planMainModelInstall(P, null, { model: "x" }, undefined); + const again = planMainModelInstall(P, first.content, { model: "y" }, first.record); + expect(again.record).toStrictEqual({ + path: P, + written: { model: "y" }, + previous: { model: null }, + created: true, + }); + }); + + it("takes a value the person set since AIDD wrote its own as the one to restore", () => { + const first = planMainModelInstall(P, '{ "model": "opus" }', { model: "x" }, undefined); + const edited = first.content.replace('"x"', '"haiku"'); + const again = planMainModelInstall(P, edited, { model: "y" }, first.record); + expect(again.record.previous).toStrictEqual({ model: "haiku" }); + expect(planMainModelRemoval(again.content, again.record)).toBe('{\n "model": "haiku"\n}\n'); + }); + + it("counts a file deleted since AIDD wrote it as one AIDD creates again", () => { + const first = planMainModelInstall(P, "{}", { model: "x" }, undefined); + const again = planMainModelInstall(P, null, { model: "y" }, first.record); + expect(again.record.created).toBe(true); + expect(planMainModelRemoval(again.content, again.record)).toBeNull(); + }); + + it("restores only unchanged keys and deletes a file it created once empty", () => { + const values = { model: "x", effortLevel: "high" }; + const created = planMainModelInstall(P, null, values, undefined); + expect(planMainModelRemoval(created.content, created.record)).toBeNull(); + const edited = created.content.replace('"high"', '"low"'); + expect(planMainModelRemoval(edited, created.record)).toBe('{\n "effortLevel": "low"\n}\n'); + const kept = planMainModelInstall(P, "{}", { model: "x" }, undefined); + expect(planMainModelRemoval(kept.content, kept.record)).toBe("{}\n"); + }); + + it("leaves missing or user-edited settings untouched on removal, and refuses unreadable ones", () => { + const record = { path: P, written: { model: "x" }, previous: { model: null }, created: false }; + expect(planMainModelRemoval(null, record)).toBeNull(); + expect(planMainModelRemoval('{"model":"mine"}', record)).toBe('{"model":"mine"}'); + expect(() => planMainModelRemoval("// broken", record)).toThrow("comments or trailing commas"); + }); +}); diff --git a/cli/tests/e2e/agent-models.e2e.test.ts b/cli/tests/e2e/agent-models.e2e.test.ts new file mode 100644 index 000000000..b44380a4e --- /dev/null +++ b/cli/tests/e2e/agent-models.e2e.test.ts @@ -0,0 +1,108 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseFrontmatter } from "../../src/kernel/markdown.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { createTestEnv, runCli } from "./helpers.js"; + +const AGENT = ".opencode/agents/aidd-orchestrator-reviewer.md"; + +async function fileExists(path: string): Promise { + return access(path).then( + () => true, + () => false + ); +} + +const SETUP = ["setup", "--source", "local", "--path", REPOSITORY_ROOT, "--ide", "opencode"]; + +async function agentModel(projectDir: string): Promise { + return parseFrontmatter(await readFile(join(projectDir, AGENT), "utf8")).frontmatter.model; +} + +/** The main session's model, in OpenCode's user config: never a file the project tracks. */ +async function mainModel(home: string): Promise { + const path = join(home, ".config", "opencode", "opencode.json"); + if (!(await fileExists(path))) return undefined; + return (JSON.parse(await readFile(path, "utf8")) as { model?: unknown }).model; +} + +describe("E2E: aidd setup --provider on a project", () => { + it("installs the agents with the provider's models, and undoes it", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("agent-models"); + try { + const install = ["--plugins", "aidd-orchestrator", "--yes"]; + const setup = await runCli([...SETUP, ...install], projectDir, fakeHome); + expect(setup.exitCode, setup.stderr).toBe(0); + expect(await agentModel(projectDir)).toBeUndefined(); + + const set = await runCli( + [...SETUP, ...install, "--provider", "mistral"], + projectDir, + fakeHome + ); + expect(set.exitCode, set.stderr).toBe(0); + expect(await agentModel(projectDir)).toBe("mistral/mistral-large-4#high"); + expect(await mainModel(fakeHome)).toBe("mistral/mistral-large-4"); + expect(await readFile(join(projectDir, "opencode.json"), "utf8")).not.toContain('"model"'); + expect(set.stdout).toContain("opencode reviewer: mistral/mistral-large-4 (high)"); + + const unset = await runCli( + [...SETUP, ...install, "--provider", "none"], + projectDir, + fakeHome + ); + expect(unset.exitCode, unset.stderr).toBe(0); + expect(await agentModel(projectDir)).toBeUndefined(); + expect(await mainModel(fakeHome)).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it("gives everything back with clean --global: main session, provider and machine-wide agents", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("agent-models-clean"); + try { + const global = [ + "setup", + "--global", + "--source", + "local", + "--path", + REPOSITORY_ROOT, + "--ide", + "opencode", + "--plugins", + "aidd-orchestrator", + "--yes", + ]; + const set = await runCli([...global, "--provider", "mistral"], projectDir, fakeHome); + expect(set.exitCode, set.stderr).toBe(0); + expect(await mainModel(fakeHome)).toBe("mistral/mistral-large-4"); + + const clean = await runCli(["clean", "--global", "--force"], projectDir, fakeHome); + expect(clean.exitCode, clean.stderr).toBe(0); + expect(clean.stdout).toContain("opencode main session: restored in"); + expect(await fileExists(join(fakeHome, ".config", "opencode", "opencode.json"))).toBe(false); + expect(await fileExists(join(fakeHome, ".config", "opencode", "agents"))).toBe(false); + } finally { + await cleanup(); + } + }); + + it("refuses a provider the IDE cannot run, and changes nothing", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("agent-models-refused"); + try { + const result = await runCli( + ["setup", "--ide", "claude", "--plugins", "none", "--provider", "mistral"], + projectDir, + fakeHome + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Provider "mistral" is not supported for "claude"'); + expect(await fileExists(join(projectDir, ".claude"))).toBe(false); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/command-matrix-help.e2e.test.ts b/cli/tests/e2e/command-matrix-help.e2e.test.ts index 96d36324d..ba31564f1 100644 --- a/cli/tests/e2e/command-matrix-help.e2e.test.ts +++ b/cli/tests/e2e/command-matrix-help.e2e.test.ts @@ -111,6 +111,7 @@ describe.concurrent("Command Matrix: Help", () => { expect(stdout).not.toContain("--ai "); expect(stdout).toContain("--ide"); expect(stdout).toContain("--global"); + expect(stdout).toContain("--provider"); expect(stdout).toContain("--plugins"); expect(stdout).toContain("--release"); expect(stdout).toContain("--yes"); diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index d2d188e6c..de900be22 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -127,7 +127,7 @@ { "invocation": "aidd setup", "exitCode": 0, - "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ide Comma-separated tool IDs, or 'all' (e.g.,\n claude,vscode or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --global install for every project on this machine instead of\n into this project; see the requirements below\n -h, --help display help for command\n\nGlobal:\n Pass --ide claude,codex,copilot,cursor,kilo,opencode (one or more, no editor) and --plugins all, recommended, none or names.\n claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.\n codex gets their files in ~/.codex, cursor gets their files in ~/.cursor/plugins/local, kilo gets their files in ~/.config/kilo, opencode gets their files in ~/.config/opencode.\n `aidd sync --global` refreshes them from the framework source." + "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ide Comma-separated tool IDs, or 'all' (e.g.,\n claude,vscode or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --global install for every project on this machine instead of\n into this project; see the requirements below\n --provider Run each tool's main session and agents on this\n provider's model for their role (openai, anthropic,\n mistral), or none for the IDE's model\n -h, --help display help for command\n\nGlobal:\n Pass --ide claude,codex,copilot,cursor,kilo,opencode (one or more, no editor) and --plugins all, recommended, none or names.\n claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.\n codex gets their files in ~/.codex, cursor gets their files in ~/.cursor/plugins/local, kilo gets their files in ~/.config/kilo, opencode gets their files in ~/.config/opencode.\n `aidd sync --global` refreshes them from the framework source." }, { "invocation": "aidd sync", diff --git a/cli/tests/helpers/ports/faulting-file-adapter.ts b/cli/tests/helpers/ports/faulting-file-adapter.ts index a6f6765d0..a87fa0922 100644 --- a/cli/tests/helpers/ports/faulting-file-adapter.ts +++ b/cli/tests/helpers/ports/faulting-file-adapter.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; -type FaultableMethod = "readFile" | "realpath" | "listDirectory" | "deleteDirectory"; +type FaultableMethod = "readFile" | "writeFile" | "realpath" | "listDirectory" | "deleteDirectory"; export class FaultingFileAdapter extends InMemoryFileAdapter { private readonly faults = new Map(); @@ -15,6 +15,11 @@ export class FaultingFileAdapter extends InMemoryFileAdapter { return super.readFile(path); } + override async writeFile(path: string, content: string): Promise { + this.throwIfFaulted("writeFile", path); + return super.writeFile(path, content); + } + override async realpath(path: string): Promise { this.throwIfFaulted("realpath", path); return super.realpath(path); diff --git a/cli/tests/presentation/commands/clean-wiring.integration.test.ts b/cli/tests/presentation/commands/clean-wiring.integration.test.ts index a1ca7d0a8..fbd7b6457 100644 --- a/cli/tests/presentation/commands/clean-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/clean-wiring.integration.test.ts @@ -3,11 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const cleanProject = vi.fn(); const cleanUserScope = vi.fn(); +const resetModels = vi.fn(); vi.mock("../../../src/runtime/wiring/framework.js", () => ({ createDeps: vi.fn(async () => ({ cleanUseCase: { execute: cleanProject }, cleanUserScopeUseCase: { execute: cleanUserScope }, + setAgentModelUseCase: { reset: resetModels }, })), createMenuDeps: vi.fn(), })); @@ -54,6 +56,7 @@ beforeEach(() => { manifestFound: true, preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, }); + resetModels.mockResolvedValue({ changed: [], skipped: [], providers: [] }); }); afterEach(() => { @@ -129,6 +132,57 @@ describe("aidd clean --scope", () => { expect(cleanProject).not.toHaveBeenCalled(); }); + it("also gives back the main sessions' models and forgets the provider, once confirmed", async () => { + resetModels.mockResolvedValue({ + changed: [{ host: "claude", path: "/home/.claude/settings.json" }], + skipped: [], + providers: ["claude"], + }); + + const lines = await run("clean", "--global", "--force"); + + expect(resetModels).toHaveBeenCalledWith({ dryRun: false }); + expect(lines.slice(-2)).toEqual([ + "claude main session: restored in /home/.claude/settings.json", + "Agents' provider for claude: forgotten.", + ]); + }); + + it("only says what it would give back while previewing", async () => { + cleanUserScope.mockResolvedValue({ + dryRun: true, + manifestFound: true, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }); + resetModels.mockResolvedValue({ + changed: [{ host: "opencode", path: "/home/.config/opencode/opencode.json" }], + skipped: [], + providers: ["opencode"], + }); + + const lines = await run("clean", "--global"); + + expect(resetModels).toHaveBeenCalledWith({ dryRun: true }); + expect(lines.slice(-2)).toEqual([ + "opencode main session: would be restored in /home/.config/opencode/opencode.json", + "Agents' provider for opencode: would be forgotten.", + ]); + }); + + it("gives them back too when the person confirms the prompt instead of passing --force", async () => { + pretendTerminal(true); + + await run("clean", "--global"); + + expect(resetModels).toHaveBeenCalledWith({ dryRun: false }); + }); + + it("leaves the machine-wide models alone when cleaning one project", async () => { + await run("clean", "--force"); + + expect(resetModels).not.toHaveBeenCalled(); + }); + it("treats a spelled-out project scope as the default one", async () => { await run("clean", "--scope", "project"); diff --git a/cli/tests/presentation/commands/setup-wiring.integration.test.ts b/cli/tests/presentation/commands/setup-wiring.integration.test.ts index 84d29583b..4805c870e 100644 --- a/cli/tests/presentation/commands/setup-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/setup-wiring.integration.test.ts @@ -20,6 +20,7 @@ const setupToolsPrompt = vi.fn(); const detectContext = vi.fn(); const setupMachineScope = vi.fn(); const loadManifest = vi.fn(); +const setAgentModel = vi.fn(); vi.mock("../../../src/runtime/wiring/framework.js", () => ({ createDeps: vi.fn(async () => ({ @@ -33,6 +34,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ setupToolsPromptUseCase: { execute: setupToolsPrompt }, projectContextDetector: { execute: detectContext }, setupMachineScopeUseCase: { execute: setupMachineScope }, + setAgentModelUseCase: { execute: setAgentModel }, })), createMenuDeps: vi.fn(), })); @@ -69,6 +71,7 @@ beforeEach(() => { return true; }); loadManifest.mockResolvedValue({}); + setAgentModel.mockResolvedValue({ reinstalled: [], changed: [], skipped: [] }); resolveSourceIfNeeded.mockResolvedValue(undefined); registerIfPresent.mockResolvedValue(undefined); activation.mockResolvedValue({ binaryMissing: [], errors: [] }); @@ -168,6 +171,109 @@ describe("aidd setup — the flow a scripted run builds", () => { }); }); +const OTHER_PROJECTS = (ide: string, provider: string) => + `Other projects follow at their next install; to update the agents they already have, run \`aidd setup --ide ${ide} --provider ${provider}\` there.`; + +describe("aidd setup — the agents' provider", () => { + it("sets the provider and the main session's model only once the install has succeeded", async () => { + await run("--ide", "opencode,vscode", "--provider", "mistral"); + + expect(setAgentModel).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + hosts: ["opencode"], + provider: "mistral", + scope: "project", + }); + expect(setAgentModel.mock.invocationCallOrder[0]).toBeGreaterThan( + resolveSourceIfNeeded.mock.invocationCallOrder[0] + ); + }); + + it("changes nothing of the provider when the install fails", async () => { + resolveSourceIfNeeded.mockRejectedValueOnce(new Error("network down")); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--ide", "opencode", "--provider", "mistral")).rejects.toThrow("exited"); + + expect(setAgentModel).not.toHaveBeenCalled(); + }); + + it("reports, per host, what the main session and each agent now run on, and what it updated", async () => { + setAgentModel.mockResolvedValue({ + reinstalled: ["aidd-orchestrator"], + changed: [{ host: "opencode", path: "/home/.config/opencode/opencode.json" }], + skipped: [], + }); + + const lines = await run("--ide", "opencode,kilo", "--provider", "mistral"); + const start = lines.indexOf("Models on mistral, for every project on this machine:"); + + expect(lines.slice(start, start + 9)).toEqual([ + "Models on mistral, for every project on this machine:", + " opencode main session: mistral/mistral-large-4, written in /home/.config/opencode/opencode.json", + " opencode reviewer: mistral/mistral-large-4 (high)", + " opencode explorer, executor, validator: mistral/mistral-small-latest (high)", + " kilo main session: not managed by aidd, still the IDE's choice", + " kilo reviewer: mistral/mistral-large-4 (high)", + " kilo explorer, executor, validator: mistral/mistral-small-latest (high)", + "Agents updated: aidd-orchestrator.", + OTHER_PROJECTS("opencode,kilo", "mistral"), + ]); + }); + + it("gives the main session and the agents back the IDE's model with none", async () => { + const lines = await run("--ide", "opencode", "--provider", "none"); + + expect(setAgentModel).toHaveBeenCalledWith( + expect.objectContaining({ hosts: ["opencode"], provider: undefined }) + ); + expect(lines).toContain("opencode: the agents run on the IDE's model again."); + expect(lines).toContain(OTHER_PROJECTS("opencode", "none")); + }); + + it("does not claim a main session it could not write, even after restoring its old file", async () => { + setAgentModel.mockResolvedValue({ + reinstalled: [], + changed: [{ host: "claude", path: "/old/settings.json" }], + skipped: [{ host: "claude", path: "/new/settings.json", reason: "it holds comments" }], + }); + + const lines = await run("--ide", "claude", "--provider", "anthropic"); + + expect(lines.filter((line) => line.includes("main session"))).toEqual([]); + expect(errors.join("")).toContain( + "claude main session left as it is in /new/settings.json: it holds comments." + ); + }); + + it("says agents installed later get the models when no plugin was updated", async () => { + setAgentModel.mockResolvedValue({ reinstalled: [], changed: [], skipped: [] }); + + const lines = await run("--ide", "opencode", "--provider", "mistral"); + + expect(lines).toContain("Agents installed later get these models too."); + }); + + it.each([ + [["--ide", "claude,codex", "--provider", "openai"], 'not supported for "claude"'], + [["--global", "--ide", "claude,vscode", "--provider", "anthropic"], "vscode"], + [["--ide", "vscode", "--provider", "bogus"], 'Unknown provider "bogus"'], + [["--ide", "vscode", "--provider", "openai"], "--provider needs --ide with a tool"], + ])("refuses %j before building or writing anything", async (args, message) => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run(...args)).rejects.toThrow("exited"); + + expect(errors.join("")).toContain(message); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + expect(setAgentModel).not.toHaveBeenCalled(); + }); +}); + describe("aidd setup — a person at a terminal", () => { it("greets, names what it detected, and ends with what to do next", async () => { pretendTerminal(true); @@ -399,6 +505,10 @@ describe("aidd setup — the help surface", () => { "install for every project on this machine instead of into this project; see the " + "requirements below", ], + [ + "--provider ", + "Run each tool's main session and agents on this provider's model for their role (openai, anthropic, mistral), or none for the IDE's model", + ], ]); expect( setupCommand() diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index e8c24281d..1b6af6ad9 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -40,12 +40,12 @@ Only `/aidd-orchestrator:01-sdlc` names an agent, by its role (`validator`), so ## 🧭 Which agent for which task -| Agent | Use it to | It returns | Writes files, by instruction | -| --- | --- | --- | --- | -| [Explorer](../plugins/aidd-orchestrator/agents/explorer.md) | Find files and symbols, trace a flow, answer a codebase question before a change | The answer, with sources and unknowns | No | -| [Executor](../plugins/aidd-orchestrator/agents/executor.md) | Write or fix code and docs within a defined scope, then verify them | `COMPLETE`, `INCOMPLETE` or `BLOCKED`, with changes and checks run | Yes | -| [Validator](../plugins/aidd-orchestrator/agents/validator.md) | Confirm delivered work meets the request and its acceptance criteria | `Validated: yes` or `no`, with what failed | No; runs checks | -| [Reviewer](../plugins/aidd-orchestrator/agents/reviewer.md) | Find defects, contradictions, duplication and needless complexity in finished work | `no actionable findings`, `changes needed` or `insufficient evidence`, with findings | No; only the review report a workflow asks for | +| Agent | Tier | Use it to | It returns | Writes files, by instruction | +| --- | --- | --- | --- | --- | +| [Explorer](../plugins/aidd-orchestrator/agents/explorer.md) | light | Find files and symbols, trace a flow, answer a codebase question before a change | The answer, with sources and unknowns | No | +| [Executor](../plugins/aidd-orchestrator/agents/executor.md) | light | Write or fix code and docs within a defined scope, then verify them | `COMPLETE`, `INCOMPLETE` or `BLOCKED`, with changes and checks run | Yes | +| [Validator](../plugins/aidd-orchestrator/agents/validator.md) | light | Confirm delivered work meets the request and its acceptance criteria | `Validated: yes` or `no`, with what failed | No; runs checks | +| [Reviewer](../plugins/aidd-orchestrator/agents/reviewer.md) | smart | Find defects, contradictions, duplication and needless complexity in finished work | `no actionable findings`, `changes needed` or `insufficient evidence`, with findings | No; only the review report a workflow asks for | ## 📦 Install @@ -60,13 +60,33 @@ aidd sync --global # after a framework update ## 🧠 Models -No model is pinned. Each host applies its own default for subagents, usually the model of the session that called them. A model you declare in an agent reaches every host in its own format. +The main session and the agents run on the IDE's model unless you pick a provider when installing: + +```bash +aidd setup --ide opencode --provider mistral # main session and agents on Mistral models +aidd setup --ide opencode --provider none # back to the IDE's model +aidd setup --global --ide codex,opencode --plugins all --provider openai # every project +``` + +| Tier | Runs | openai | anthropic | mistral | +| --- | --- | --- | --- | --- | +| smart | Reviewer | `gpt-6.1-sol` | `claude-opus-5-5` | `mistral-large-4` | +| standard | Main session | `gpt-6.1-terra` | `claude-sonnet-5-5` | `mistral-large-4` | +| light | Explorer, executor, validator | `gpt-6-luna` | `claude-haiku-5-5` | `mistral-small-latest` | + +Claude Code takes anthropic, Codex openai, Copilot openai or anthropic, Cursor anthropic, OpenCode and Kilo all three. A run takes one provider, so every `--ide` it names must take it: `--ide claude,codex` is refused. The mapping lives in [`agent-models.json`](../cli/src/contexts/tools/domain/formats/agent-models.json). + +- The provider holds for the whole machine: other projects get it at their next install; to update the agents they already have, run `aidd setup --ide … --provider …` there, `none` included. +- Nothing changes until the install has succeeded. +- The main session's model is written in the IDE's own user settings, so it changes every project on the machine: Claude Code's `settings.json` (under `CLAUDE_CONFIG_DIR` when set) and OpenCode's `~/.config/opencode/opencode.json`, or `opencode.jsonc` when that is the one there. `--provider none` restores what it replaced. A settings file holding comments is left alone, and the report says so. A `model` in a project's own settings still wins. Codex, Copilot, Cursor and Kilo keep the model picked in the IDE. +- Effort: smart runs at `high`, the others at `medium`. Mistral Small takes no effort between `none` and `high`, so the light tier runs at `high`. +- The command ends with a report: per IDE, the model of the main session and of each agent, and the plugins it updated. ## 🚧 Limits - **Tools:** every agent inherits the calling session's tools and permissions; what it writes is an instruction, not a restriction. - **Codex:** a Codex plugin cannot carry agents, so only the flat distribution installs them, in `.codex/agents/`, and Codex loads them only in a trusted project; `aidd setup --global` puts them in `~/.codex/agents/` instead. -- **Models:** OpenCode V1 reads the same file but not the `#` variant. +- **Models:** OpenCode V1 reads the same file but not the `#` variant. Only an install through `aidd setup` pins them, not `/plugin install`. `aidd clean` cleans one project and keeps the machine-wide provider; `aidd clean --global --force` forgets it and restores the main session's model. - **Updates:** reinstalling replaces the agents still shipped; one removed from the plugin stays where a copy put it until you delete it. Sources: [Claude Code sub-agents](https://code.claude.com/docs/en/sub-agents), [Copilot custom agents](https://docs.github.com/en/copilot/reference/custom-agents-configuration), [OpenCode agents](https://opencode.ai/v2/docs/agents), [Codex subagents](https://developers.openai.com/codex/subagents), [Cursor subagents](https://cursor.com/docs/subagents). diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index e14d3b8d7..c95e3a7f8 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -5,6 +5,7 @@ # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool # whose CLI is absent is skipped, and the first run needs network. +# The agents' provider is whatever `aidd setup --global --provider` last recorded. # # Global rules: sync only the Agents section in the managed block, preserving personal # rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. Kilo