feat(cli): auto-configure Athena councils
This commit is contained in:
@@ -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
|
||||
|
||||
129
src/cli/config-manager/generate-athena-config.ts
Normal file
129
src/cli/config-manager/generate-athena-config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
102
src/cli/config-manager/generate-omo-config.test.ts
Normal file
102
src/cli/config-manager/generate-omo-config.test.ts
Normal 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" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
return generateModelConfig(installConfig)
|
||||
const generatedConfig = generateModelConfig(installConfig)
|
||||
const athenaConfig = generateAthenaConfig(installConfig)
|
||||
|
||||
if (!athenaConfig) {
|
||||
return generatedConfig
|
||||
}
|
||||
|
||||
return {
|
||||
...generatedConfig,
|
||||
athena: athenaConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
71
src/plugin-config-partial.ts
Normal file
71
src/plugin-config-partial.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): OhMyOpenCodeConfig | null {
|
||||
const fullResult = OhMyOpenCodeConfigSchema.safeParse(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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user