feat(cli): auto-configure Athena councils
This commit is contained in:
@@ -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
|
||||||
|
|||||||
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 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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
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 { 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user