diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 49256d2da7b..a492cb0a94a 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -4,11 +4,13 @@ import { run } from "./run" import { getLocalVersion } from "./get-local-version" import { doctor } from "./doctor" import { refreshModelCapabilities } from "./refresh-model-capabilities" +import { updateModels } from "./update-models/update-models" import { createMcpOAuthCommand } from "./mcp-oauth" import type { InstallArgs } from "./types" import type { RunOptions } from "./run" import type { GetLocalVersionOptions } from "./get-local-version/types" import type { DoctorOptions } from "./doctor" +import type { UpdateModelsOptions } from "./update-models/types" import packageJson from "../../package.json" with { type: "json" } const VERSION = packageJson.version @@ -195,6 +197,38 @@ program process.exit(exitCode) }) +program + .command("update-models") + .description("Update model mappings in oh-my-opencode config") + .option("-d, --directory ", "Working directory to read config from") + .option("--full", "Replace all model mappings with latest recommendations") + .option("--dry-run", "Preview changes without modifying config") + .option("--json", "Output as JSON") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode update-models # Preserve custom mappings + $ bunx oh-my-opencode update-models --dry-run # Preview changes + $ bunx oh-my-opencode update-models --full # Replace all mappings + $ bunx oh-my-opencode update-models --json # JSON output + +Modes: + preserve-custom (default): Update only non-customized entries + full-replacement (--full): Replace all entries with latest defaults +`) + .action(async (options) => { + const updateModelsOptions: UpdateModelsOptions = { + directory: options.directory, + mode: options.full ? "full-replacement" : "preserve-custom", + dryRun: options.dryRun ?? false, + json: options.json ?? false, + } + const result = await updateModels(updateModelsOptions) + if (!result.success) { + console.error(result.message) + } + process.exit(result.success ? 0 : 1) + }) + program .command("version") .description("Show version information") diff --git a/src/cli/config-manager/detect-current-config.ts b/src/cli/config-manager/detect-current-config.ts index e1fb3982337..81bcd732e64 100644 --- a/src/cli/config-manager/detect-current-config.ts +++ b/src/cli/config-manager/detect-current-config.ts @@ -6,7 +6,7 @@ import { detectConfigFormat } from "./opencode-config-format" import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file" import { extractVersionFromPluginEntry } from "./version-compatibility" -function detectProvidersFromOmoConfig(): { +function detectProvidersFromOmoConfig(projectDir?: string): { hasOpenAI: boolean hasOpencodeZen: boolean hasZaiCodingPlan: boolean @@ -14,7 +14,9 @@ function detectProvidersFromOmoConfig(): { hasOpencodeGo: boolean hasVercelAiGateway: boolean } { - const omoConfigPath = getOmoConfigPath() + const omoConfigPath = projectDir + ? `${projectDir}/.opencode/oh-my-openagent.json` + : getOmoConfigPath() if (!existsSync(omoConfigPath)) { return { hasOpenAI: true, @@ -70,7 +72,7 @@ function findOurPluginEntry(plugins: string[]): string | null { return plugins.find(isOurPlugin) ?? null } -export function detectCurrentConfig(): DetectedConfig { +export function detectCurrentConfig(directory?: string): DetectedConfig { const result: DetectedConfig = { isInstalled: false, installedVersion: null, @@ -86,7 +88,9 @@ export function detectCurrentConfig(): DetectedConfig { hasVercelAiGateway: false, } - const { format, path } = detectConfigFormat() + const { format, path } = directory + ? detectConfigFormat(directory) + : detectConfigFormat() if (format === "none") { return result } @@ -112,7 +116,7 @@ export function detectCurrentConfig(): DetectedConfig { const providers = openCodeConfig.provider as Record | undefined result.hasGemini = providers ? "google" in providers : false - const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo, hasVercelAiGateway } = detectProvidersFromOmoConfig() + const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo, hasVercelAiGateway } = detectProvidersFromOmoConfig(directory) result.hasOpenAI = hasOpenAI result.hasOpencodeZen = hasOpencodeZen result.hasZaiCodingPlan = hasZaiCodingPlan @@ -121,4 +125,4 @@ export function detectCurrentConfig(): DetectedConfig { result.hasVercelAiGateway = hasVercelAiGateway return result -} +} \ No newline at end of file diff --git a/src/cli/config-manager/opencode-config-format.ts b/src/cli/config-manager/opencode-config-format.ts index 135cb511cb1..265f984f43e 100644 --- a/src/cli/config-manager/opencode-config-format.ts +++ b/src/cli/config-manager/opencode-config-format.ts @@ -1,9 +1,23 @@ import { existsSync } from "node:fs" +import { join } from "path" import { getConfigJson, getConfigJsonc } from "./config-context" export type ConfigFormat = "json" | "jsonc" | "none" -export function detectConfigFormat(): { format: ConfigFormat; path: string } { +export function detectConfigFormat(directory?: string): { format: ConfigFormat; path: string } { + if (directory) { + const configJsonc = join(directory, "opencode.jsonc") + const configJson = join(directory, "opencode.json") + + if (existsSync(configJsonc)) { + return { format: "jsonc", path: configJsonc } + } + if (existsSync(configJson)) { + return { format: "json", path: configJson } + } + return { format: "none", path: configJson } + } + const configJsonc = getConfigJsonc() const configJson = getConfigJson() @@ -14,4 +28,4 @@ export function detectConfigFormat(): { format: ConfigFormat; path: string } { return { format: "json", path: configJson } } return { format: "none", path: configJson } -} +} \ No newline at end of file diff --git a/src/cli/update-models.test.ts b/src/cli/update-models.test.ts new file mode 100644 index 00000000000..40006de6a13 --- /dev/null +++ b/src/cli/update-models.test.ts @@ -0,0 +1,554 @@ +import { describe, expect, it, mock, spyOn } from "bun:test" +import { updateModels, type UpdateModelsDeps } from "./update-models/update-models" +import type { UpdateModelsOptions, ModelMappingEntry, UpdateModelsResult } from "./update-models/types" + +describe("updateModels", () => { + const defaultGeneratedAgents: Record = { + oracle: { model: "anthropic/claude-opus-4-7" }, + sisyphus: { model: "anthropic/claude-sonnet-4" }, + librarian: { model: "openai/gpt-5.4-mini-fast" }, + explore: { model: "openai/gpt-5.4-mini-fast" }, + } + + const defaultGeneratedCategories: Record = { + "unspecified-high": { model: "anthropic/claude-sonnet-4" }, + "unspecified-low": { model: "openai/gpt-5.4-mini-fast" }, + coding: { model: "anthropic/claude-sonnet-4" }, + } + + function createMockDeps(overrides: Partial = {}): UpdateModelsDeps { + return { + loadConfig: mock(() => ({ + agents: { ...defaultGeneratedAgents }, + categories: { ...defaultGeneratedCategories }, + })), + detectCurrentConfig: mock(() => ({ + providers: ["anthropic", "openai"], + })), + generateModelConfig: mock(() => ({ + agents: { ...defaultGeneratedAgents }, + categories: { ...defaultGeneratedCategories }, + })), + compareMappings: mock((current, generated) => { + const toUpdate: Record = {} + const toPreserve: string[] = [] + const toAdd: Record = {} + + for (const [key, generatedEntry] of Object.entries(generated)) { + const currentEntry = current[key] + if (currentEntry === undefined) { + toAdd[key] = generatedEntry + } else if ( + currentEntry.model === generatedEntry.model && + currentEntry.variant === generatedEntry.variant && + JSON.stringify(currentEntry.fallback_models) === + JSON.stringify(generatedEntry.fallback_models) + ) { + toPreserve.push(key) + } else { + toUpdate[key] = generatedEntry + } + } + + return { toUpdate, toPreserve, toAdd } + }), + backupConfigFile: mock(() => ({ + success: true, + backupPath: "/test/oh-my-openagent.json.backup-2026-01-01T00-00-00-000Z", + })), + writeFile: mock(() => {}), + readFile: mock(() => ""), + existsFile: mock(() => true), + renameFile: mock(() => {}), + ...overrides, + } + } + + function createMockOptions(overrides: Partial = {}): UpdateModelsOptions { + return { + directory: "/test", + mode: "preserve-custom", + dryRun: false, + json: false, + ...overrides, + } + } + + describe("preserve-custom mode with no customizations", () => { + it("updates all entries when all match defaults", async () => { + const deps = createMockDeps() + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.preserved).toContain("agents.oracle") + expect(result.preserved).toContain("agents.sisyphus") + expect(result.preserved).toContain("categories.coding") + }) + }) + + describe("preserve-custom mode with customizations", () => { + it("preserves customized entries and updates non-customized", async () => { + const customConfig = { + agents: { + oracle: { model: "custom/oracle-model" }, + sisyphus: { model: "anthropic/claude-sonnet-4" }, + librarian: { model: "openai/gpt-5.4-mini-fast" }, + explore: { model: "openai/gpt-5.4-mini-fast" }, + }, + categories: { + "unspecified-high": { model: "anthropic/claude-sonnet-4" }, + "unspecified-low": { model: "openai/gpt-5.4-mini-fast" }, + coding: { model: "custom/coding-model" }, + }, + } + + const deps = createMockDeps({ + loadConfig: mock(() => customConfig), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.preserved).toContain("agents.sisyphus") + expect(result.preserved).toContain("agents.librarian") + expect(result.preserved).toContain("agents.explore") + expect(result.preserved).toContain("agents.oracle") + expect(result.preserved).toContain("categories.unspecified-high") + expect(result.preserved).toContain("categories.unspecified-low") + }) + + it("preserves entries with different variants", async () => { + const customConfig = { + agents: { + oracle: { model: "anthropic/claude-opus-4-7", variant: "custom-variant" }, + sisyphus: { model: "anthropic/claude-sonnet-4" }, + }, + categories: {}, + } + + const deps = createMockDeps({ + loadConfig: mock(() => customConfig), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.preserved).toContain("agents.sisyphus") + }) + }) + + describe("full-replacement mode", () => { + it("replaces all entries regardless of customization", async () => { + const customConfig = { + agents: { + oracle: { model: "custom/oracle-model" }, + sisyphus: { model: "custom/sisyphus-model" }, + }, + categories: { + coding: { model: "custom/coding-model" }, + }, + } + + const deps = createMockDeps({ + loadConfig: mock(() => customConfig), + }) + const options = createMockOptions({ mode: "full-replacement" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.updated.length).toBeGreaterThan(0) + expect(result.preserved.length).toBe(0) + }) + }) + + describe("config file doesn't exist", () => { + it("returns failure with helpful message when config doesn't exist", async () => { + const deps = createMockDeps({ + loadConfig: mock(() => null), + }) + const options = createMockOptions() + + const result = await updateModels(options, deps) + + expect(result.success).toBe(false) + expect(result.message).toContain("No oh-my-openagent.json found") + expect(result.message).toContain("oh-my-opencode install") + }) + }) + + describe("custom agents/categories preserved", () => { + it("preserves non-default entries that don't exist in generated config", async () => { + const configWithCustomEntries = { + agents: { + oracle: { model: "anthropic/claude-opus-4-7" }, + customAgent: { model: "custom/model" }, + }, + categories: { + coding: { model: "anthropic/claude-sonnet-4" }, + customCategory: { model: "custom/category-model" }, + }, + } + + const deps = createMockDeps({ + loadConfig: mock(() => configWithCustomEntries), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.preserved).toContain("agents.oracle") + }) + }) + + describe("non-model properties untouched", () => { + it("preserves prompt, tools, and disable properties", async () => { + const configWithExtraProps = { + agents: { + oracle: { + model: "anthropic/claude-opus-4-7", + prompt: "Custom oracle prompt", + tools: ["tool1", "tool2"], + }, + }, + categories: { + coding: { + model: "anthropic/claude-sonnet-4", + disable: true, + }, + }, + someOtherProperty: "should be preserved", + } + + let writtenConfig: Record | null = null + const deps = createMockDeps({ + loadConfig: mock(() => configWithExtraProps), + writeFile: mock((path: string, content: string) => { + writtenConfig = JSON.parse(content) + }), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + await updateModels(options, deps) + + expect(writtenConfig).not.toBeNull() + expect(writtenConfig?.someOtherProperty).toBe("should be preserved") + }) + }) + + describe("backup created before write", () => { + it("creates backup file before modifying config", async () => { + const backupPath = "/test/oh-my-openagent.json.backup-2026-04-30T12-00-00-000Z" + const deps = createMockDeps({ + backupConfigFile: mock(() => ({ + success: true, + backupPath, + })), + }) + const options = createMockOptions() + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.backupPath).toBe(backupPath) + expect(deps.backupConfigFile).toHaveBeenCalled() + }) + + it("includes backup path in result message", async () => { + const backupPath = "/test/oh-my-openagent.json.backup-2026-04-30T12-00-00-000Z" + const deps = createMockDeps({ + backupConfigFile: mock(() => ({ + success: true, + backupPath, + })), + }) + const options = createMockOptions() + + const result = await updateModels(options, deps) + + expect(result.backupPath).toBe(backupPath) + }) + }) + + describe("new entries added", () => { + it("adds new agents and categories from generated config", async () => { + const existingConfig = { + agents: { + oracle: { model: "anthropic/claude-opus-4-7" }, + }, + categories: {}, + } + + const newGeneratedAgents = { + oracle: { model: "anthropic/claude-opus-4-7" }, + sisyphus: { model: "anthropic/claude-sonnet-4" }, + librarian: { model: "openai/gpt-5.4-mini-fast" }, + } + + const deps = createMockDeps({ + loadConfig: mock(() => existingConfig), + generateModelConfig: mock(() => ({ + agents: newGeneratedAgents, + categories: defaultGeneratedCategories, + })), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.added).toContain("agents.sisyphus") + expect(result.added).toContain("agents.librarian") + }) + }) + + describe("dry-run mode", () => { + it("does not modify file in dry-run mode", async () => { + const writeFileMock = mock(() => {}) + const deps = createMockDeps({ + writeFile: writeFileMock, + }) + const options = createMockOptions({ dryRun: true, mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(result.message).toContain("Dry run") + expect(writeFileMock).not.toHaveBeenCalled() + }) + + it("shows diff information in dry-run mode", async () => { + const deps = createMockDeps() + const options = createMockOptions({ dryRun: true, mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.message).toContain("would be updated") + expect(result.message).toContain("preserved") + expect(result.message).toContain("added") + }) + }) + + describe("JSON output mode", () => { + it("outputs JSON when json option is true", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}) + const deps = createMockDeps() + const options = createMockOptions({ json: true, mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + expect(logSpy).toHaveBeenCalled() + + const loggedCall = logSpy.mock.calls[0] + expect(loggedCall).toBeDefined() + if (loggedCall && loggedCall[0]) { + const loggedJson = JSON.parse(loggedCall[0] as string) + expect(loggedJson).toHaveProperty("success") + expect(loggedJson).toHaveProperty("message") + expect(loggedJson).toHaveProperty("updated") + expect(loggedJson).toHaveProperty("preserved") + expect(loggedJson).toHaveProperty("added") + } + + logSpy.mockRestore() + }) + + it("includes all result fields in JSON output", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}) + const deps = createMockDeps() + const options = createMockOptions({ json: true, mode: "preserve-custom" }) + + await updateModels(options, deps) + + const loggedCall = logSpy.mock.calls[0] + expect(loggedCall).toBeDefined() + if (loggedCall && loggedCall[0]) { + const loggedJson = JSON.parse(loggedCall[0] as string) as UpdateModelsResult & { + success: boolean + message: string + } + expect(Array.isArray(loggedJson.updated)).toBe(true) + expect(Array.isArray(loggedJson.preserved)).toBe(true) + expect(Array.isArray(loggedJson.added)).toBe(true) + } + + logSpy.mockRestore() + }) + }) + + describe("special-case agents", () => { + it("assigns correct models for librarian agent", async () => { + let generatedConfig: { agents: Record } | null = null + const deps = createMockDeps({ + generateModelConfig: mock((config) => { + generatedConfig = { + agents: { + librarian: { model: "openai/gpt-5.4-mini-fast" }, + oracle: { model: "anthropic/claude-opus-4-7" }, + sisyphus: { model: "anthropic/claude-sonnet-4" }, + explore: { model: "openai/gpt-5.4-mini-fast" }, + }, + categories: defaultGeneratedCategories, + } + return generatedConfig + }), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + await updateModels(options, deps) + + expect(generatedConfig).not.toBeNull() + expect(generatedConfig?.agents.librarian?.model).toBe("openai/gpt-5.4-mini-fast") + }) + + it("assigns correct models for explore agent", async () => { + let generatedConfig: { agents: Record } | null = null + const deps = createMockDeps({ + generateModelConfig: mock((config) => { + generatedConfig = { + agents: { + librarian: { model: "openai/gpt-5.4-mini-fast" }, + oracle: { model: "anthropic/claude-opus-4-7" }, + sisyphus: { model: "anthropic/claude-sonnet-4" }, + explore: { model: "openai/gpt-5.4-mini-fast" }, + }, + categories: defaultGeneratedCategories, + } + return generatedConfig + }), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + await updateModels(options, deps) + + expect(generatedConfig).not.toBeNull() + expect(generatedConfig?.agents.explore?.model).toBe("openai/gpt-5.4-mini-fast") + }) + + it("assigns correct models for sisyphus agent with fallback chain", async () => { + let generatedConfig: { agents: Record } | null = null + const deps = createMockDeps({ + generateModelConfig: mock((config) => { + generatedConfig = { + agents: { + librarian: { model: "openai/gpt-5.4-mini-fast" }, + oracle: { model: "anthropic/claude-opus-4-7" }, + sisyphus: { + model: "anthropic/claude-sonnet-4", + fallback_models: [ + { model: "openai/gpt-5.4" }, + { model: "opencode/claude-sonnet-4" }, + ], + }, + explore: { model: "openai/gpt-5.4-mini-fast" }, + }, + categories: defaultGeneratedCategories, + } + return generatedConfig + }), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + await updateModels(options, deps) + + expect(generatedConfig).not.toBeNull() + expect(generatedConfig?.agents.sisyphus?.model).toBe("anthropic/claude-sonnet-4") + expect(generatedConfig?.agents.sisyphus?.fallback_models).toBeDefined() + expect(generatedConfig?.agents.sisyphus?.fallback_models?.length).toBeGreaterThan(0) + }) + }) + + describe("fallback_models handling", () => { + it("preserves entries with custom fallback_models", async () => { + const configWithCustomFallbacks = { + agents: { + oracle: { + model: "anthropic/claude-opus-4-7", + fallback_models: [ + { model: "custom/fallback-1" }, + { model: "custom/fallback-2" }, + ], + }, + }, + categories: {}, + } + + const deps = createMockDeps({ + loadConfig: mock(() => configWithCustomFallbacks), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + }) + + it("detects matching fallback_models as default", async () => { + const configWithMatchingFallbacks = { + agents: { + sisyphus: { + model: "anthropic/claude-sonnet-4", + fallback_models: [{ model: "openai/gpt-5.4" }], + }, + }, + categories: {}, + } + + const deps = createMockDeps({ + loadConfig: mock(() => configWithMatchingFallbacks), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + }) + }) + + describe("edge cases", () => { + it("handles empty agents and categories", async () => { + const deps = createMockDeps({ + loadConfig: mock(() => ({ + agents: {}, + categories: {}, + })), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + }) + + it("handles config with only non-model properties", async () => { + const deps = createMockDeps({ + loadConfig: mock(() => ({ + someSetting: "value", + anotherSetting: 123, + })), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + }) + + it("handles missing agents or categories keys", async () => { + const deps = createMockDeps({ + loadConfig: mock(() => ({})), + }) + const options = createMockOptions({ mode: "preserve-custom" }) + + const result = await updateModels(options, deps) + + expect(result.success).toBe(true) + }) + }) +}) diff --git a/src/cli/update-models/compare-mappings.test.ts b/src/cli/update-models/compare-mappings.test.ts new file mode 100644 index 00000000000..e3a43afaca6 --- /dev/null +++ b/src/cli/update-models/compare-mappings.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from "bun:test" +import { isDefaultEntry, compareMappings } from "./compare-mappings.js" +import type { ModelMappingEntry } from "./types.js" + +describe("isDefaultEntry", () => { + it("returns true for identical entries with no fallback_models", () => { + const current: ModelMappingEntry = { model: "claude-sonnet-4" } + const generated: ModelMappingEntry = { model: "claude-sonnet-4" } + expect(isDefaultEntry(current, generated)).toBe(true) + }) + + it("returns true for identical entries with matching fallback_models", () => { + const current: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [{ model: "claude-3-5-sonnet-20241022" }], + } + const generated: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [{ model: "claude-3-5-sonnet-20241022" }], + } + expect(isDefaultEntry(current, generated)).toBe(true) + }) + + it("returns false when model differs", () => { + const current: ModelMappingEntry = { model: "claude-sonnet-4" } + const generated: ModelMappingEntry = { model: "claude-3-5-sonnet" } + expect(isDefaultEntry(current, generated)).toBe(false) + }) + + it("returns false when variant differs", () => { + const current: ModelMappingEntry = { model: "claude-sonnet-4", variant: "custom" } + const generated: ModelMappingEntry = { model: "claude-sonnet-4", variant: "standard" } + expect(isDefaultEntry(current, generated)).toBe(false) + }) + + it("returns false when fallback_models count differs", () => { + const current: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [{ model: "claude-3-5-sonnet-20241022" }], + } + const generated: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [], + } + expect(isDefaultEntry(current, generated)).toBe(false) + }) + + it("returns false when fallback_models content differs", () => { + const current: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [{ model: "claude-3-5-sonnet-20241022" }], + } + const generated: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [{ model: "claude-3-opus" }], + } + expect(isDefaultEntry(current, generated)).toBe(false) + }) + + it("handles undefined fallback_models vs empty array", () => { + const current: ModelMappingEntry = { model: "claude-sonnet-4" } + const generated: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [], + } + expect(isDefaultEntry(current, generated)).toBe(true) + }) + + it("handles multiple fallback models", () => { + const current: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [ + { model: "claude-3-5-sonnet-20241022" }, + { model: "claude-3-haiku" }, + ], + } + const generated: ModelMappingEntry = { + model: "claude-sonnet-4", + fallback_models: [ + { model: "claude-3-5-sonnet-20241022" }, + { model: "claude-3-haiku" }, + ], + } + expect(isDefaultEntry(current, generated)).toBe(true) + }) +}) + +describe("compareMappings", () => { + it("returns empty results for identical mappings", () => { + const current: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + } + const generated: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + } + const result = compareMappings(current, generated) + expect(result.toUpdate).toEqual({}) + expect(result.toPreserve).toEqual(["claude-sonnet"]) + expect(result.toAdd).toEqual({}) + }) + + it("marks new entries in generated as toAdd", () => { + const current: Record = {} + const generated: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + } + const result = compareMappings(current, generated) + expect(result.toAdd).toEqual({ "claude-sonnet": { model: "claude-sonnet-4" } }) + expect(result.toPreserve).toEqual([]) + expect(result.toUpdate).toEqual({}) + }) + + it("marks custom entries (not matching defaults) as toUpdate", () => { + const current: Record = { + "claude-sonnet": { model: "claude-sonnet-4", variant: "custom" }, + } + const generated: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + } + const result = compareMappings(current, generated) + expect(result.toUpdate).toEqual({ "claude-sonnet": { model: "claude-sonnet-4" } }) + expect(result.toPreserve).toEqual([]) + }) + + it("marks default-matching entries as toPreserve", () => { + const current: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + } + const generated: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + } + const result = compareMappings(current, generated) + expect(result.toPreserve).toEqual(["claude-sonnet"]) + }) + + it("handles multiple entries with mixed results", () => { + const current: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + "claude-opus": { model: "claude-opus-3", variant: "custom" }, + "gemini": { model: "gemini-2-5-pro" }, + } + const generated: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + "claude-opus": { model: "claude-opus-3" }, + "gemini": { model: "gemini-2-5-pro" }, + "llama": { model: "llama-3-1-8b" }, + } + const result = compareMappings(current, generated) + expect(result.toPreserve).toEqual(["claude-sonnet", "gemini"]) + expect(result.toUpdate).toEqual({ "claude-opus": { model: "claude-opus-3" } }) + expect(result.toAdd).toEqual({ "llama": { model: "llama-3-1-8b" } }) + }) + + it("handles empty current mapping", () => { + const current: Record = {} + const generated: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + "gemini": { model: "gemini-2-5-pro" }, + } + const result = compareMappings(current, generated) + expect(result.toAdd).toEqual(generated) + expect(result.toPreserve).toEqual([]) + expect(result.toUpdate).toEqual({}) + }) + + it("handles empty generated mapping", () => { + const current: Record = { + "claude-sonnet": { model: "claude-sonnet-4" }, + } + const generated: Record = {} + const result = compareMappings(current, generated) + expect(result.toAdd).toEqual({}) + expect(result.toPreserve).toEqual([]) + expect(result.toUpdate).toEqual({}) + }) +}) \ No newline at end of file diff --git a/src/cli/update-models/compare-mappings.ts b/src/cli/update-models/compare-mappings.ts new file mode 100644 index 00000000000..96be1eb2520 --- /dev/null +++ b/src/cli/update-models/compare-mappings.ts @@ -0,0 +1,58 @@ +import type { ModelMappingEntry } from "./types.js" + +export function isDefaultEntry( + currentEntry: ModelMappingEntry, + generatedEntry: ModelMappingEntry +): boolean { + if (currentEntry.model !== generatedEntry.model) { + return false + } + + if (currentEntry.variant !== generatedEntry.variant) { + return false + } + + const currentFallbacks = currentEntry.fallback_models ?? [] + const generatedFallbacks = generatedEntry.fallback_models ?? [] + + if (currentFallbacks.length !== generatedFallbacks.length) { + return false + } + + for (let i = 0; i < currentFallbacks.length; i++) { + if (currentFallbacks[i].model !== generatedFallbacks[i].model) { + return false + } + } + + return true +} + +export interface CompareMappingsResult { + toUpdate: Record + toPreserve: string[] + toAdd: Record +} + +export function compareMappings( + current: Record, + generated: Record +): CompareMappingsResult { + const toUpdate: Record = {} + const toPreserve: string[] = [] + const toAdd: Record = {} + + for (const [key, generatedEntry] of Object.entries(generated)) { + const currentEntry = current[key] + + if (currentEntry === undefined) { + toAdd[key] = generatedEntry + } else if (isDefaultEntry(currentEntry, generatedEntry)) { + toPreserve.push(key) + } else { + toUpdate[key] = generatedEntry + } + } + + return { toUpdate, toPreserve, toAdd } +} \ No newline at end of file diff --git a/src/cli/update-models/index.ts b/src/cli/update-models/index.ts new file mode 100644 index 00000000000..bd54d946380 --- /dev/null +++ b/src/cli/update-models/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js" +export * from "./compare-mappings.js" \ No newline at end of file diff --git a/src/cli/update-models/types.ts b/src/cli/update-models/types.ts new file mode 100644 index 00000000000..4a5680e606a --- /dev/null +++ b/src/cli/update-models/types.ts @@ -0,0 +1,23 @@ +export type UpdateModelsMode = "preserve-custom" | "full-replacement" + +export interface UpdateModelsOptions { + directory?: string + mode: UpdateModelsMode + dryRun?: boolean + json?: boolean +} + +export interface UpdateModelsResult { + success: boolean + message: string + updated: string[] + preserved: string[] + added: string[] + backupPath?: string +} + +export interface ModelMappingEntry { + model?: string + fallback_models?: Array<{ model: string }> + variant?: string +} \ No newline at end of file diff --git a/src/cli/update-models/update-models.ts b/src/cli/update-models/update-models.ts new file mode 100644 index 00000000000..972d74b24a8 --- /dev/null +++ b/src/cli/update-models/update-models.ts @@ -0,0 +1,199 @@ +import { loadPluginConfig } from "../../plugin-config.js" +import { detectCurrentConfig } from "../config-manager/detect-current-config.js" +import { generateModelConfig } from "../model-fallback.js" +import { backupConfigFile } from "../config-manager/backup-config.js" +import { compareMappings } from "./compare-mappings.js" +import type { UpdateModelsOptions, UpdateModelsResult, ModelMappingEntry } from "./types.js" +import type { InstallConfig } from "../types.js" +import type { GeneratedOmoConfig } from "../model-fallback-types.js" +import { readFileSync, writeFileSync, renameSync, existsSync } from "fs" +import { join } from "path" + +export interface UpdateModelsDeps { + loadConfig?: (directory: string) => Record | null + detectCurrentConfig?: () => InstallConfig + generateModelConfig?: (config: InstallConfig) => GeneratedOmoConfig + compareMappings?: typeof compareMappings + backupConfigFile?: (configPath: string) => { success: boolean; backupPath?: string } + writeFile?: (path: string, content: string) => void + readFile?: (path: string) => string + existsFile?: (path: string) => boolean + renameFile?: (oldPath: string, newPath: string) => void +} + +export async function updateModels( + options: UpdateModelsOptions, + deps: UpdateModelsDeps = {} +): Promise { + const { + loadConfig = loadExistingConfig, + detectCurrentConfig: detect = detectCurrentConfig, + generateModelConfig: generate = generateModelConfig, + compareMappings: compare = compareMappings, + backupConfigFile: backup = backupConfigFile, + writeFile = writeFileSync, + readFile = readFileSync, + existsFile = existsSync, + renameFile = renameSync, + } = deps + + const directory = options.directory ?? process.cwd() + const configPath = join(directory, "oh-my-openagent.json") + + // Load existing config + const existingConfig = loadConfig(directory) + if (!existingConfig) { + return { + success: false, + message: "No oh-my-openagent.json found. Run `oh-my-opencode install` first.", + updated: [], + preserved: [], + added: [], + } + } + + // Detect providers from existing config + const providerConfig = detect(directory) + + // Generate new defaults based on detected providers + const generatedConfig = generate(providerConfig) + + // Extract current mappings from existing config + const currentAgents = (existingConfig.agents as Record) || {} + const currentCategories = (existingConfig.categories as Record) || {} + + const generatedAgents = generatedConfig.agents || {} + const generatedCategories = generatedConfig.categories || {} + + // Compare and determine what to update + const agentsComparison = compare(currentAgents, generatedAgents as Record) + const categoriesComparison = compare(currentCategories, generatedCategories as Record) + + const updated: string[] = [] + const preserved: string[] = [] + const added: string[] = [] + + // Build new config based on mode + let newAgents: Record + let newCategories: Record + + if (options.mode === "full-replacement") { + // Merge generated model properties INTO existing entries to preserve non-model properties + newAgents = { ...currentAgents } + newCategories = { ...currentCategories } + + for (const [key, value] of Object.entries(generatedAgents)) { + newAgents[key] = { ...currentAgents[key], ...value } + updated.push(`agents.${key}`) + } + + for (const [key, value] of Object.entries(generatedCategories)) { + newCategories[key] = { ...currentCategories[key], ...value } + updated.push(`categories.${key}`) + } + } else { + newAgents = { ...currentAgents } + newCategories = { ...currentCategories } + + for (const key of Object.keys(agentsComparison.toUpdate)) { + preserved.push(`agents.${key}`) + } + + for (const key of agentsComparison.toPreserve) { + preserved.push(`agents.${key}`) + } + + for (const [key, value] of Object.entries(agentsComparison.toAdd)) { + newAgents[key] = value + added.push(`agents.${key}`) + } + + for (const key of Object.keys(categoriesComparison.toUpdate)) { + preserved.push(`categories.${key}`) + } + + for (const key of categoriesComparison.toPreserve) { + preserved.push(`categories.${key}`) + } + + for (const [key, value] of Object.entries(categoriesComparison.toAdd)) { + newCategories[key] = value + added.push(`categories.${key}`) + } + } + + // If dry run, just return what would change + if (options.dryRun) { + const result: UpdateModelsResult = { + success: true, + message: `Dry run: ${updated.length} entries would be updated, ${preserved.length} preserved, ${added.length} added`, + updated, + preserved, + added, + } + + if (options.json) { + console.log(JSON.stringify(result, null, 2)) + } else { + console.log(result.message) + } + + return result + } + + // Create backup before writing + let backupPath: string | undefined + if (existsFile(configPath)) { + const backupResult = backup(configPath) + if (backupResult.success && backupResult.backupPath) { + backupPath = backupResult.backupPath + } + } + + // Build new config preserving non-model properties + const newConfig = { + ...existingConfig, + agents: newAgents, + categories: newCategories, + } + + // Atomic write: write to temp file then rename + const tempPath = `${configPath}.tmp` + writeFile(tempPath, JSON.stringify(newConfig, null, 2) + "\n") + renameFile(tempPath, configPath) + + const result: UpdateModelsResult = { + success: true, + message: `Updated ${updated.length} entries, preserved ${preserved.length}, added ${added.length}`, + updated, + preserved, + added, + backupPath, + } + + if (options.json) { + console.log(JSON.stringify(result, null, 2)) + } else { + console.log(result.message) + if (backupPath) { + console.log(`Backup created: ${backupPath}`) + } + } + + return result +} + +function loadExistingConfig(directory: string): Record | null { + const configPath = join(directory, "oh-my-openagent.json") + try { + if (!existsSync(configPath)) { + return null + } + const content = readFileSync(configPath, "utf-8") + return JSON.parse(content) as Record + } catch { + return null + } +} + +export default updateModels