diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts index 5d5fd0ca5..ec5d9b20b 100644 --- a/src/cli/cli-installer.test.ts +++ b/src/cli/cli-installer.test.ts @@ -34,6 +34,7 @@ describe("runCliInstaller", () => { hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, + hasOpencodeGo: false, }), spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"), @@ -56,6 +57,7 @@ describe("runCliInstaller", () => { opencodeZen: "no", zaiCodingPlan: "no", kimiForCoding: "no", + opencodeGo: "no", } //#when diff --git a/src/cli/config-manager/generate-athena-config.ts b/src/cli/config-manager/generate-athena-config.ts new file mode 100644 index 000000000..fdc5c5e1d --- /dev/null +++ b/src/cli/config-manager/generate-athena-config.ts @@ -0,0 +1,129 @@ +import { transformModelForProvider } from "../../shared/provider-model-id-transform" +import { toProviderAvailability } from "../provider-availability" +import type { InstallConfig } from "../types" + +export interface AthenaMemberTemplate { + provider: string + model: string + name: string + isAvailable: (config: InstallConfig) => boolean +} + +export interface AthenaCouncilMember { + name: string + model: string +} + +export interface AthenaConfig { + model?: string + members: AthenaCouncilMember[] +} + +const ATHENA_MEMBER_TEMPLATES: AthenaMemberTemplate[] = [ + { + provider: "openai", + model: "gpt-5.4", + name: "OpenAI Strategist", + isAvailable: (config) => config.hasOpenAI, + }, + { + provider: "anthropic", + model: "claude-sonnet-4-6", + name: "Claude Strategist", + isAvailable: (config) => config.hasClaude, + }, + { + provider: "google", + model: "gemini-3.1-pro", + name: "Gemini Strategist", + isAvailable: (config) => config.hasGemini, + }, + { + provider: "github-copilot", + model: "gpt-5.4", + name: "Copilot Strategist", + isAvailable: (config) => config.hasCopilot, + }, + { + provider: "opencode", + model: "gpt-5.4", + name: "OpenCode Strategist", + isAvailable: (config) => config.hasOpencodeZen, + }, + { + provider: "zai-coding-plan", + model: "glm-4.7", + name: "Z.ai Strategist", + isAvailable: (config) => config.hasZaiCodingPlan, + }, + { + provider: "kimi-for-coding", + model: "k2p5", + name: "Kimi Strategist", + isAvailable: (config) => config.hasKimiForCoding, + }, + { + provider: "opencode-go", + model: "glm-5", + name: "OpenCode Go Strategist", + isAvailable: (config) => config.hasOpencodeGo, + }, +] + +function toProviderModel(provider: string, model: string): string { + const transformedModel = transformModelForProvider(provider, model) + return `${provider}/${transformedModel}` +} + +function createUniqueMemberName(baseName: string, usedNames: Set): string { + if (!usedNames.has(baseName.toLowerCase())) { + usedNames.add(baseName.toLowerCase()) + return baseName + } + + let suffix = 2 + let candidate = `${baseName} ${suffix}` + while (usedNames.has(candidate.toLowerCase())) { + suffix += 1 + candidate = `${baseName} ${suffix}` + } + + usedNames.add(candidate.toLowerCase()) + return candidate +} + +export function createAthenaCouncilMembersFromTemplates( + templates: AthenaMemberTemplate[] +): AthenaCouncilMember[] { + const members: AthenaCouncilMember[] = [] + const usedNames = new Set() + + for (const template of templates) { + members.push({ + name: createUniqueMemberName(template.name, usedNames), + model: toProviderModel(template.provider, template.model), + }) + } + + return members +} + +export function generateAthenaConfig(config: InstallConfig): AthenaConfig | undefined { + const selectedTemplates = ATHENA_MEMBER_TEMPLATES.filter((template) => template.isAvailable(config)) + if (selectedTemplates.length === 0) { + return undefined + } + + const members = createAthenaCouncilMembersFromTemplates(selectedTemplates) + const availability = toProviderAvailability(config) + + const preferredCoordinator = + (availability.native.openai && members.find((member) => member.model.startsWith("openai/"))) || + (availability.native.claude && members.find((member) => member.model.startsWith("anthropic/"))) || + members[0] + + return { + model: preferredCoordinator.model, + members, + } +} diff --git a/src/cli/config-manager/generate-omo-config.test.ts b/src/cli/config-manager/generate-omo-config.test.ts new file mode 100644 index 000000000..a55dfceab --- /dev/null +++ b/src/cli/config-manager/generate-omo-config.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "bun:test" +import type { InstallConfig } from "../types" +import { + createAthenaCouncilMembersFromTemplates, + generateAthenaConfig, + type AthenaMemberTemplate, +} from "./generate-athena-config" +import { generateOmoConfig } from "./generate-omo-config" +import { transformModelForProvider } from "../../shared/provider-model-id-transform" + +function createInstallConfig(overrides: Partial = {}): InstallConfig { + return { + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + ...overrides, + } +} + +describe("generateOmoConfig athena council", () => { + it("creates athena council members from enabled providers", () => { + // given + const installConfig = createInstallConfig({ hasOpenAI: true, hasClaude: true, hasGemini: true }) + + // when + const generated = generateOmoConfig(installConfig) + const athena = generated.athena as { model?: string; members?: Array<{ name: string; model: string }> } + const googleModel = `google/${transformModelForProvider("google", "gemini-3.1-pro")}` + + // then + expect(athena.model).toBe("openai/gpt-5.4") + expect(athena.members).toHaveLength(3) + expect(athena.members?.map((member) => member.model)).toEqual([ + "openai/gpt-5.4", + "anthropic/claude-sonnet-4-6", + googleModel, + ]) + }) + + it("does not create athena config when no providers are enabled", () => { + // given + const installConfig = createInstallConfig() + + // when + const generated = generateOmoConfig(installConfig) + + // then + expect(generated.athena).toBeUndefined() + }) +}) + +describe("generateAthenaConfig", () => { + it("uses anthropic as coordinator when openai is unavailable", () => { + // given + const installConfig = createInstallConfig({ hasClaude: true, hasCopilot: true }) + + // when + const athena = generateAthenaConfig(installConfig) + + // then + expect(athena?.model).toBe("anthropic/claude-sonnet-4-6") + expect(athena?.members?.map((member) => member.model)).toEqual([ + "anthropic/claude-sonnet-4-6", + "github-copilot/gpt-5.4", + ]) + }) +}) + +describe("createAthenaCouncilMembersFromTemplates", () => { + it("adds numeric suffixes when template names collide case-insensitively", () => { + // given + const templates: AthenaMemberTemplate[] = [ + { + provider: "openai", + model: "gpt-5.4", + name: "Strategist", + isAvailable: () => true, + }, + { + provider: "anthropic", + model: "claude-sonnet-4-6", + name: "strategist", + isAvailable: () => true, + }, + ] + + // when + const members = createAthenaCouncilMembersFromTemplates(templates) + + // then + expect(members).toEqual([ + { name: "Strategist", model: "openai/gpt-5.4" }, + { name: "strategist 2", model: "anthropic/claude-sonnet-4-6" }, + ]) + }) +}) diff --git a/src/cli/config-manager/generate-omo-config.ts b/src/cli/config-manager/generate-omo-config.ts index c7060dad2..17b5763a3 100644 --- a/src/cli/config-manager/generate-omo-config.ts +++ b/src/cli/config-manager/generate-omo-config.ts @@ -1,6 +1,17 @@ import type { InstallConfig } from "../types" import { generateModelConfig } from "../model-fallback" +import { generateAthenaConfig } from "./generate-athena-config" export function generateOmoConfig(installConfig: InstallConfig): Record { - return generateModelConfig(installConfig) + const generatedConfig = generateModelConfig(installConfig) + const athenaConfig = generateAthenaConfig(installConfig) + + if (!athenaConfig) { + return generatedConfig + } + + return { + ...generatedConfig, + athena: athenaConfig, + } } diff --git a/src/cli/config-manager/write-omo-config.test.ts b/src/cli/config-manager/write-omo-config.test.ts index 5701b53dc..48ae5c620 100644 --- a/src/cli/config-manager/write-omo-config.test.ts +++ b/src/cli/config-manager/write-omo-config.test.ts @@ -18,6 +18,7 @@ const installConfig: InstallConfig = { hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, + hasOpencodeGo: false, } function getRecord(value: unknown): Record { diff --git a/src/plugin-config-partial.ts b/src/plugin-config-partial.ts new file mode 100644 index 000000000..eeb75350f --- /dev/null +++ b/src/plugin-config-partial.ts @@ -0,0 +1,71 @@ +import { type OhMyOpenCodeConfig, OhMyOpenCodeConfigSchema } from "./config" + +const PARTIAL_STRING_ARRAY_KEYS = new Set([ + "disabled_mcps", + "disabled_agents", + "disabled_skills", + "disabled_hooks", + "disabled_commands", + "disabled_tools", +]) + +export interface PartialConfigParseResult { + config: OhMyOpenCodeConfig + invalidSections: string[] +} + +function formatIssue(path: PropertyKey[], message: string): string { + const pathText = path.length > 0 ? path.join(".") : "root" + return `${pathText}: ${message}` +} + +export function parseConfigPartiallyWithIssues( + rawConfig: Record +): PartialConfigParseResult { + const fullResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig) + if (fullResult.success) { + return { + config: fullResult.data, + invalidSections: [], + } + } + + const partialConfig: Record = {} + const invalidSections: string[] = [] + + for (const key of Object.keys(rawConfig)) { + if (PARTIAL_STRING_ARRAY_KEYS.has(key)) { + const sectionValue = rawConfig[key] + if (Array.isArray(sectionValue) && sectionValue.every((value) => typeof value === "string")) { + partialConfig[key] = sectionValue + } else { + invalidSections.push(formatIssue([key], "Expected an array of strings")) + } + continue + } + + const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] }) + if (sectionResult.success) { + const parsed = sectionResult.data as Record + if (parsed[key] !== undefined) { + partialConfig[key] = parsed[key] + } + continue + } + + const sectionIssues = sectionResult.error.issues.filter((issue) => issue.path[0] === key) + const issuesToReport = sectionIssues.length > 0 ? sectionIssues : sectionResult.error.issues + const sectionErrors = issuesToReport + .map((issue) => formatIssue(issue.path, issue.message)) + .join(", ") + + if (sectionErrors.length > 0) { + invalidSections.push(`${key}: ${sectionErrors}`) + } + } + + return { + config: partialConfig as OhMyOpenCodeConfig, + invalidSections, + } +} diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 47e1cf537..512a1c4ea 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { mergeConfigs, parseConfigPartially } from "./plugin-config"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { mergeConfigs, loadConfigFromPath, parseConfigPartially } from "./plugin-config"; +import { parseConfigPartiallyWithIssues } from "./plugin-config-partial"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; +import { clearConfigLoadErrors, getConfigLoadErrors } from "./shared"; describe("mergeConfigs", () => { describe("categories merging", () => { @@ -136,6 +141,50 @@ describe("mergeConfigs", () => { }); describe("parseConfigPartially", () => { + describe("athena config", () => { + //#given athena config with valid members and model format + //#when parsing partially + //#then athena section should be preserved + it("should preserve valid athena config", () => { + const rawConfig = { + athena: { + model: "openai/gpt-5.4", + members: [ + { name: "Socrates", model: "openai/gpt-5.4" }, + { name: "Plato", model: "anthropic/claude-sonnet-4-6" }, + ], + }, + } + + const result = parseConfigPartially(rawConfig) + + expect(result).not.toBeNull() + expect(result?.athena?.model).toBe("openai/gpt-5.4") + expect(result?.athena?.members).toHaveLength(2) + }) + + //#given athena config with duplicate member names by case + //#when parsing partially + //#then athena section should be dropped as invalid + it("should drop invalid athena config with case-insensitive duplicate member names", () => { + const rawConfig = { + athena: { + members: [ + { name: "Socrates", model: "openai/gpt-5.4" }, + { name: "socrates", model: "anthropic/claude-sonnet-4-6" }, + ], + }, + disabled_hooks: ["comment-checker"], + } + + const result = parseConfigPartially(rawConfig) + + expect(result).not.toBeNull() + expect(result?.athena).toBeUndefined() + expect(result?.disabled_hooks).toEqual(["comment-checker"]) + }) + }) + describe("disabled_hooks compatibility", () => { //#given a config with a future hook name unknown to this version //#when validating against the full config schema @@ -271,3 +320,68 @@ describe("parseConfigPartially", () => { }); }); }); + +describe("parseConfigPartiallyWithIssues", () => { + it("surfaces athena validation messages while keeping valid sections", () => { + // given + const rawConfig = { + athena: { + members: [ + { name: "Socrates", model: "openai/gpt-5.4" }, + { name: "socrates", model: "anthropic/claude-sonnet-4-6" }, + ], + }, + disabled_hooks: ["comment-checker"], + }; + + // when + const result = parseConfigPartiallyWithIssues(rawConfig); + + // then + expect(result.config.athena).toBeUndefined(); + expect(result.config.disabled_hooks).toEqual(["comment-checker"]); + expect(result.invalidSections.length).toBeGreaterThan(0); + expect(result.invalidSections.join(" ")).toContain("Duplicate member name"); + }); +}); + +describe("loadConfigFromPath", () => { + it("records actionable error details for invalid athena config", () => { + // given + clearConfigLoadErrors(); + const fixtureDir = mkdtempSync(join(tmpdir(), "omo-config-load-")); + const configPath = join(fixtureDir, "oh-my-opencode.jsonc"); + try { + writeFileSync( + configPath, + JSON.stringify( + { + athena: { + members: [ + { name: "Socrates", model: "openai/gpt-5.4" }, + { name: "socrates", model: "anthropic/claude-sonnet-4-6" }, + ], + }, + disabled_hooks: ["comment-checker"], + }, + null, + 2, + ), + ); + + // when + const loaded = loadConfigFromPath(configPath, {}); + const loadErrors = getConfigLoadErrors(); + + // then + expect(loaded?.athena).toBeUndefined(); + expect(loaded?.disabled_hooks).toEqual(["comment-checker"]); + expect(loadErrors).toHaveLength(1); + expect(loadErrors[0]?.error).toContain("Invalid sections"); + expect(loadErrors[0]?.error).toContain("Duplicate member name"); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + clearConfigLoadErrors(); + } + }); +}); diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 67322f17a..5cba788e8 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import * as path from "path"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; +import { parseConfigPartiallyWithIssues } from "./plugin-config-partial"; import { log, deepMerge, @@ -11,57 +12,16 @@ import { migrateConfigFile, } from "./shared"; -const PARTIAL_STRING_ARRAY_KEYS = new Set([ - "disabled_mcps", - "disabled_agents", - "disabled_skills", - "disabled_hooks", - "disabled_commands", - "disabled_tools", -]); - export function parseConfigPartially( rawConfig: Record ): OhMyOpenCodeConfig | null { - const fullResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig); - if (fullResult.success) { - return fullResult.data; - } - - const partialConfig: Record = {}; - const invalidSections: string[] = []; - - for (const key of Object.keys(rawConfig)) { - if (PARTIAL_STRING_ARRAY_KEYS.has(key)) { - const sectionValue = rawConfig[key]; - if (Array.isArray(sectionValue) && sectionValue.every((value) => typeof value === "string")) { - partialConfig[key] = sectionValue; - } - continue; - } - - const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] }); - if (sectionResult.success) { - const parsed = sectionResult.data as Record; - if (parsed[key] !== undefined) { - partialConfig[key] = parsed[key]; - } - } else { - const sectionErrors = sectionResult.error.issues - .filter((i) => i.path[0] === key) - .map((i) => `${i.path.join(".")}: ${i.message}`) - .join(", "); - if (sectionErrors) { - invalidSections.push(`${key}: ${sectionErrors}`); - } - } - } + const { config, invalidSections } = parseConfigPartiallyWithIssues(rawConfig); if (invalidSections.length > 0) { log("Partial config loaded — invalid sections skipped:", invalidSections); } - return partialConfig as OhMyOpenCodeConfig; + return config; } export function loadConfigFromPath( @@ -86,15 +46,21 @@ export function loadConfigFromPath( .map((i) => `${i.path.join(".")}: ${i.message}`) .join(", "); log(`Config validation error in ${configPath}:`, result.error.issues); + + const partialResult = parseConfigPartiallyWithIssues(rawConfig); + const partialErrorDetails = + partialResult.invalidSections.length > 0 + ? partialResult.invalidSections.join("; ") + : errorMsg; + addConfigLoadError({ path: configPath, - error: `Partial config loaded — invalid sections skipped: ${errorMsg}`, + error: `Config validation failed. Loaded valid sections only. Invalid sections: ${partialErrorDetails}`, }); - const partialResult = parseConfigPartially(rawConfig); - if (partialResult) { - log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents }); - return partialResult; + if (partialResult.config) { + log(`Partial config loaded from ${configPath}`, { agents: partialResult.config.agents }); + return partialResult.config; } return null;