feat(omo-codex): link bundled component agents into codex home
Discover component .toml agent files and symlink (or copy on Windows) them into the Codex agents dir during install, recording an installed agent manifest and wiring agent config_file entries into config.toml. Add CodexAgentConfig type and support local marketplace source.
This commit is contained in:
@@ -41,6 +41,11 @@ describe("codex-config-toml", () => {
|
||||
},
|
||||
pluginNames: ["omo"],
|
||||
trustedHookStates: [{ key: "omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }],
|
||||
agentConfigs: [
|
||||
{ name: "explorer", configFile: "./agents/explorer.toml" },
|
||||
{ name: "librarian", configFile: "./agents/librarian.toml" },
|
||||
{ name: "plan", configFile: "./agents/plan.toml" },
|
||||
],
|
||||
})
|
||||
await updateCodexConfig({
|
||||
configPath,
|
||||
@@ -53,6 +58,11 @@ describe("codex-config-toml", () => {
|
||||
},
|
||||
pluginNames: ["omo"],
|
||||
trustedHookStates: [{ key: "omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }],
|
||||
agentConfigs: [
|
||||
{ name: "explorer", configFile: "./agents/explorer.toml" },
|
||||
{ name: "librarian", configFile: "./agents/librarian.toml" },
|
||||
{ name: "plan", configFile: "./agents/plan.toml" },
|
||||
],
|
||||
})
|
||||
|
||||
// then
|
||||
@@ -66,8 +76,47 @@ describe("codex-config-toml", () => {
|
||||
expect(content).toContain('ref = "main"')
|
||||
expect(content).toContain("[plugins.\"omo@sisyphuslabs\"]")
|
||||
expect(content).toContain("[hooks.state.\"omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0\"]")
|
||||
expect(content).toContain("[agents.explorer]")
|
||||
expect(content).toContain('config_file = "./agents/explorer.toml"')
|
||||
expect(content).toContain("[agents.librarian]")
|
||||
expect(content).toContain('config_file = "./agents/librarian.toml"')
|
||||
expect(content).toContain("[agents.plan]")
|
||||
expect(content).toContain('config_file = "./agents/plan.toml"')
|
||||
expect(content).not.toContain("[marketplaces.lazycodex]")
|
||||
expect(content).not.toContain("code-yeongyu-codex-plugins")
|
||||
expect(content).not.toContain('source_type = "local"')
|
||||
})
|
||||
|
||||
test("repairs existing agent config_file entries without dropping descriptions", async () => {
|
||||
// given
|
||||
const root = await mkdtemp(join(tmpdir(), "omo-codex-config-agents-"))
|
||||
const configPath = join(root, "config.toml")
|
||||
await writeFile(
|
||||
configPath,
|
||||
[
|
||||
"[agents.explorer]",
|
||||
'description = "existing description"',
|
||||
'config_file = "./agents/stale-explorer.toml"',
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// when
|
||||
await updateCodexConfig({
|
||||
configPath,
|
||||
repoRoot: "/repo/packages/omo-codex",
|
||||
marketplaceName: "debug",
|
||||
marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" },
|
||||
pluginNames: ["omo"],
|
||||
agentConfigs: [{ name: "explorer", configFile: "./agents/explorer.toml" }],
|
||||
})
|
||||
|
||||
// then
|
||||
const content = await readFile(configPath, "utf8")
|
||||
expect(content).toContain("[agents.explorer]")
|
||||
expect(content).toContain('description = "existing description"')
|
||||
expect(content).toContain('config_file = "./agents/explorer.toml"')
|
||||
expect(content).not.toContain("stale-explorer")
|
||||
expect(content).not.toContain("ref = undefined")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { dirname } from "node:path"
|
||||
import type { CodexMarketplaceSource, TrustedHookState } from "./types"
|
||||
import type { CodexAgentConfig, CodexMarketplaceSource, TrustedHookState } from "./types"
|
||||
|
||||
const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function updateCodexConfig(input: {
|
||||
readonly marketplaceSource: CodexMarketplaceSource
|
||||
readonly pluginNames: readonly string[]
|
||||
readonly trustedHookStates?: readonly TrustedHookState[]
|
||||
readonly agentConfigs?: readonly CodexAgentConfig[]
|
||||
}): Promise<void> {
|
||||
await mkdir(dirname(input.configPath), { recursive: true })
|
||||
let config = ""
|
||||
@@ -33,6 +34,9 @@ export async function updateCodexConfig(input: {
|
||||
for (const state of input.trustedHookStates ?? []) {
|
||||
config = ensureHookTrusted(config, state.key, state.trustedHash)
|
||||
}
|
||||
for (const agentConfig of input.agentConfigs ?? []) {
|
||||
config = ensureAgentConfig(config, agentConfig)
|
||||
}
|
||||
|
||||
await writeFile(input.configPath, `${config.trimEnd()}\n`)
|
||||
}
|
||||
@@ -78,14 +82,17 @@ function ensureFeatureEnabled(config: string, featureName: string): string {
|
||||
|
||||
function ensureMarketplaceBlock(config: string, marketplaceName: string, source: CodexMarketplaceSource): string {
|
||||
const header = `marketplaces.${marketplaceName}`
|
||||
const block = [
|
||||
const lines = [
|
||||
`[${header}]`,
|
||||
`last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`,
|
||||
`source_type = ${JSON.stringify(source.sourceType)}`,
|
||||
`source = ${JSON.stringify(source.source)}`,
|
||||
`ref = ${JSON.stringify(source.ref)}`,
|
||||
"",
|
||||
].join("\n")
|
||||
]
|
||||
if (source.sourceType === "git") {
|
||||
lines.push(`ref = ${JSON.stringify(source.ref)}`)
|
||||
}
|
||||
lines.push("")
|
||||
const block = lines.join("\n")
|
||||
const section = findTomlSection(config, header)
|
||||
if (section) return config.slice(0, section.start) + block + config.slice(section.end)
|
||||
return appendBlock(
|
||||
@@ -108,6 +115,18 @@ function ensureHookTrusted(config: string, key: string, trustedHash: string): st
|
||||
return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash))
|
||||
}
|
||||
|
||||
function ensureAgentConfig(config: string, agentConfig: CodexAgentConfig): string {
|
||||
const header = `agents.${tomlKeySegment(agentConfig.name)}`
|
||||
const section = findTomlSection(config, header)
|
||||
const configFile = JSON.stringify(agentConfig.configFile)
|
||||
if (!section) return appendBlock(config, `[${header}]\nconfig_file = ${configFile}\n`)
|
||||
return replaceOrInsertSetting(config, section, "config_file", configFile)
|
||||
}
|
||||
|
||||
function tomlKeySegment(value: string): string {
|
||||
return /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
function removeTomlSections(config: string, shouldRemove: (header: string) => boolean): string {
|
||||
return splitTomlSections(config)
|
||||
.filter((section) => section.header === null || !shouldRemove(section.header))
|
||||
|
||||
@@ -5,9 +5,50 @@ import { describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { runCodexInstaller } from "./install-codex"
|
||||
import { resolveCodexInstallerBinDir, runCodexInstaller } from "./install-codex"
|
||||
|
||||
describe("install-codex", () => {
|
||||
test("#given default CODEX_HOME #when resolving installer bin dir without override #then preserves user local bin precedence", () => {
|
||||
// given
|
||||
const homeDir = join(tmpdir(), "omo-codex-home-default")
|
||||
const codexHome = join(homeDir, ".codex")
|
||||
|
||||
// when
|
||||
const binDir = resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir })
|
||||
|
||||
// then
|
||||
expect(binDir).toBe(join(homeDir, ".local", "bin"))
|
||||
})
|
||||
|
||||
test("#given custom CODEX_HOME #when resolving installer bin dir without override #then keeps generated omo inside that Codex home", () => {
|
||||
// given
|
||||
const homeDir = join(tmpdir(), "omo-codex-home-custom")
|
||||
const codexHome = join(tmpdir(), "omo-codex-install-custom")
|
||||
|
||||
// when
|
||||
const binDir = resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir })
|
||||
|
||||
// then
|
||||
expect(binDir).toBe(join(codexHome, "bin"))
|
||||
})
|
||||
|
||||
test("#given explicit CODEX_LOCAL_BIN_DIR #when resolving installer bin dir #then preserves installed omo precedence", () => {
|
||||
// given
|
||||
const homeDir = join(tmpdir(), "omo-codex-home-explicit")
|
||||
const codexHome = join(tmpdir(), "omo-codex-install-explicit")
|
||||
const explicitBinDir = join(tmpdir(), "omo-codex-explicit-bin")
|
||||
|
||||
// when
|
||||
const binDir = resolveCodexInstallerBinDir({
|
||||
codexHome,
|
||||
env: { CODEX_LOCAL_BIN_DIR: explicitBinDir },
|
||||
homeDir,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(binDir).toBe(explicitBinDir)
|
||||
})
|
||||
|
||||
test("installs vendored plugin into codex home and stays idempotent", async () => {
|
||||
// given
|
||||
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-"))
|
||||
@@ -32,6 +73,12 @@ describe("install-codex", () => {
|
||||
expect(configContent).toContain('ref = "main"')
|
||||
expect(configContent).toContain("[plugins.\"omo@sisyphuslabs\"]")
|
||||
expect(configContent).toContain("[hooks.state.")
|
||||
expect(configContent).toContain("[agents.explorer]")
|
||||
expect(configContent).toContain('config_file = "./agents/explorer.toml"')
|
||||
expect(configContent).toContain("[agents.librarian]")
|
||||
expect(configContent).toContain('config_file = "./agents/librarian.toml"')
|
||||
expect(configContent).toContain("[agents.plan]")
|
||||
expect(configContent).toContain('config_file = "./agents/plan.toml"')
|
||||
expect(configContent).not.toContain("code-yeongyu-codex-plugins")
|
||||
expect(configContent).not.toContain("[marketplaces.lazycodex]")
|
||||
|
||||
@@ -40,6 +87,9 @@ describe("install-codex", () => {
|
||||
expect(pluginPath).toContain(join("plugins", "cache", "sisyphuslabs", "omo"))
|
||||
const stats = await stat(pluginPath ?? "")
|
||||
expect(stats.isDirectory()).toBe(true)
|
||||
expect((await stat(join(codexHome, "agents", "explorer.toml"))).isFile()).toBe(true)
|
||||
expect((await stat(join(codexHome, "agents", "librarian.toml"))).isFile()).toBe(true)
|
||||
expect((await stat(join(codexHome, "agents", "plan.toml"))).isFile()).toBe(true)
|
||||
await expect(stat(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo"))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plu
|
||||
export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise<CodexInstallResult> {
|
||||
const repoRoot = resolve(options.repoRoot ?? findRepoRootFromImporter(import.meta.dir))
|
||||
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"))
|
||||
const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin"))
|
||||
const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env: process.env })
|
||||
const runCommand = options.runCommand ?? defaultRunCommand
|
||||
const log = options.log ?? (() => undefined)
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
})
|
||||
|
||||
const installed: InstalledPlugin[] = []
|
||||
const agentConfigs = new Map<string, { readonly name: string; readonly configFile: string }>()
|
||||
for (const entry of marketplace.plugins) {
|
||||
const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" })
|
||||
const manifest = await readPluginManifest(sourcePath)
|
||||
@@ -58,6 +59,8 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot: plugin.path })
|
||||
for (const link of agentLinks) {
|
||||
log(`Linked agent ${link.name} -> ${link.target}`)
|
||||
const agentName = agentNameFromToml(link.name)
|
||||
agentConfigs.set(agentName, { name: agentName, configFile: `./agents/${link.name}` })
|
||||
}
|
||||
installed.push(plugin)
|
||||
}
|
||||
@@ -95,6 +98,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
marketplaceSource: LAZYCODEX_MARKETPLACE_SOURCE,
|
||||
pluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||||
trustedHookStates,
|
||||
agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||
})
|
||||
|
||||
await trackCodexInstallTelemetry()
|
||||
@@ -107,6 +111,26 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCodexInstallerBinDir(input: {
|
||||
readonly binDir?: string
|
||||
readonly codexHome: string
|
||||
readonly env?: { readonly [key: string]: string | undefined }
|
||||
readonly homeDir?: string
|
||||
}): string {
|
||||
const explicitBinDir = input.binDir ?? input.env?.CODEX_LOCAL_BIN_DIR
|
||||
if (explicitBinDir !== undefined && explicitBinDir.trim().length > 0) return resolve(explicitBinDir)
|
||||
|
||||
const homeDir = input.homeDir ?? homedir()
|
||||
const defaultCodexHome = resolve(homeDir, ".codex")
|
||||
const resolvedCodexHome = resolve(input.codexHome)
|
||||
if (resolvedCodexHome !== defaultCodexHome) return join(resolvedCodexHome, "bin")
|
||||
return resolve(homeDir, ".local", "bin")
|
||||
}
|
||||
|
||||
function agentNameFromToml(fileName: string): string {
|
||||
return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName
|
||||
}
|
||||
|
||||
function legacyCacheMarketplaces(marketplaceName: string): readonly string[] {
|
||||
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : []
|
||||
}
|
||||
|
||||
@@ -30,10 +30,20 @@ export interface TrustedHookState {
|
||||
readonly trustedHash: string
|
||||
}
|
||||
|
||||
export interface CodexMarketplaceSource {
|
||||
readonly sourceType: "git"
|
||||
readonly source: string
|
||||
readonly ref: string
|
||||
export type CodexMarketplaceSource =
|
||||
| {
|
||||
readonly sourceType: "git"
|
||||
readonly source: string
|
||||
readonly ref: string
|
||||
}
|
||||
| {
|
||||
readonly sourceType: "local"
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
export interface CodexAgentConfig {
|
||||
readonly name: string
|
||||
readonly configFile: string
|
||||
}
|
||||
|
||||
export interface CommandRunOptions {
|
||||
|
||||
Reference in New Issue
Block a user