feat(cli): auto-configure Athena councils

This commit is contained in:
YeonGyu-Kim
2026-03-26 12:59:44 +09:00
parent 1c125ec3ef
commit 4a14bd6d68
8 changed files with 446 additions and 50 deletions

View File

@@ -34,6 +34,7 @@ describe("runCliInstaller", () => {
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
hasOpencodeGo: false,
}), }),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"), spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
@@ -56,6 +57,7 @@ describe("runCliInstaller", () => {
opencodeZen: "no", opencodeZen: "no",
zaiCodingPlan: "no", zaiCodingPlan: "no",
kimiForCoding: "no", kimiForCoding: "no",
opencodeGo: "no",
} }
//#when //#when

View File

@@ -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>): 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<string>()
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,
}
}

View File

@@ -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> = {}): 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" },
])
})
})

View File

@@ -1,6 +1,17 @@
import type { InstallConfig } from "../types" import type { InstallConfig } from "../types"
import { generateModelConfig } from "../model-fallback" import { generateModelConfig } from "../model-fallback"
import { generateAthenaConfig } from "./generate-athena-config"
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> { export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
return generateModelConfig(installConfig) const generatedConfig = generateModelConfig(installConfig)
const athenaConfig = generateAthenaConfig(installConfig)
if (!athenaConfig) {
return generatedConfig
}
return {
...generatedConfig,
athena: athenaConfig,
}
} }

View File

@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
hasOpencodeGo: false,
} }
function getRecord(value: unknown): Record<string, unknown> { function getRecord(value: unknown): Record<string, unknown> {

View File

@@ -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<string, unknown>
): PartialConfigParseResult {
const fullResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
if (fullResult.success) {
return {
config: fullResult.data,
invalidSections: [],
}
}
const partialConfig: Record<string, unknown> = {}
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<string, unknown>
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,
}
}

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from "bun:test"; 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 { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import { clearConfigLoadErrors, getConfigLoadErrors } from "./shared";
describe("mergeConfigs", () => { describe("mergeConfigs", () => {
describe("categories merging", () => { describe("categories merging", () => {
@@ -136,6 +141,50 @@ describe("mergeConfigs", () => {
}); });
describe("parseConfigPartially", () => { 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", () => { describe("disabled_hooks compatibility", () => {
//#given a config with a future hook name unknown to this version //#given a config with a future hook name unknown to this version
//#when validating against the full config schema //#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();
}
});
});

View File

@@ -1,6 +1,7 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import { parseConfigPartiallyWithIssues } from "./plugin-config-partial";
import { import {
log, log,
deepMerge, deepMerge,
@@ -11,57 +12,16 @@ import {
migrateConfigFile, migrateConfigFile,
} from "./shared"; } from "./shared";
const PARTIAL_STRING_ARRAY_KEYS = new Set([
"disabled_mcps",
"disabled_agents",
"disabled_skills",
"disabled_hooks",
"disabled_commands",
"disabled_tools",
]);
export function parseConfigPartially( export function parseConfigPartially(
rawConfig: Record<string, unknown> rawConfig: Record<string, unknown>
): OhMyOpenCodeConfig | null { ): OhMyOpenCodeConfig | null {
const fullResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig); const { config, invalidSections } = parseConfigPartiallyWithIssues(rawConfig);
if (fullResult.success) {
return fullResult.data;
}
const partialConfig: Record<string, unknown> = {};
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<string, unknown>;
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}`);
}
}
}
if (invalidSections.length > 0) { if (invalidSections.length > 0) {
log("Partial config loaded — invalid sections skipped:", invalidSections); log("Partial config loaded — invalid sections skipped:", invalidSections);
} }
return partialConfig as OhMyOpenCodeConfig; return config;
} }
export function loadConfigFromPath( export function loadConfigFromPath(
@@ -86,15 +46,21 @@ export function loadConfigFromPath(
.map((i) => `${i.path.join(".")}: ${i.message}`) .map((i) => `${i.path.join(".")}: ${i.message}`)
.join(", "); .join(", ");
log(`Config validation error in ${configPath}:`, result.error.issues); log(`Config validation error in ${configPath}:`, result.error.issues);
const partialResult = parseConfigPartiallyWithIssues(rawConfig);
const partialErrorDetails =
partialResult.invalidSections.length > 0
? partialResult.invalidSections.join("; ")
: errorMsg;
addConfigLoadError({ addConfigLoadError({
path: configPath, 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.config) {
if (partialResult) { log(`Partial config loaded from ${configPath}`, { agents: partialResult.config.agents });
log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents }); return partialResult.config;
return partialResult;
} }
return null; return null;