diff --git a/README.md b/README.md index 02093937..7565a673 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ Configuration is loaded from `piv-loop.config.ts` and resolved into project-spec - `name` (optional) - overrides such as `workspacePath`, `executionPath`, `repo`, `linear`, `codex`, `skills`, `dryRun` +Codex capability configuration can be set at root or per project: + +- `codex.plugins`: plugin IDs to enable for spawned Codex sessions (translated to `--config plugins."".enabled=true`) +- `codex.skillsets`: skillset names passed to Codex as a `skillsets=[...]` config override +- `codex.configOverrides`: raw `key -> TOML literal` map forwarded as repeated `--config key=value` + Path behavior: - `workspacePath`: where PIV loop stores state and temp artifacts. diff --git a/piv-loop.config.ts b/piv-loop.config.ts index d11b54a7..d8cddb01 100644 --- a/piv-loop.config.ts +++ b/piv-loop.config.ts @@ -42,6 +42,11 @@ const config: DeepPartial = { implement: "gpt-5.3-codex", reviewTest: "gpt-5.3-codex", }, + plugins: ["github@openai-curated", "linear@openai-curated"], + skillsets: ["piv-loop"], + configOverrides: { + "features.codex_hooks": "true", + }, }, skills: { plan: path.join(cwd, "skills", "piv-plan", "SKILL.md"), diff --git a/src/codex.ts b/src/codex.ts index a58e9ae4..1b50db95 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -36,6 +36,7 @@ export function buildCodexExecArgs( if (config.codex.sandbox) { args.push("--sandbox", config.codex.sandbox); } + appendCodexConfigArgs(args, config); args.push(prompt); return args; } @@ -59,6 +60,7 @@ export function buildCodexResumeArgs( if (model) { args.push("--model", model); } + appendCodexConfigArgs(args, config); args.push(sessionId, prompt); return args; } @@ -275,3 +277,49 @@ function findStringByKey(value: unknown, keys: string[]): string | undefined { } return undefined; } + +function appendCodexConfigArgs( + args: string[], + config: ResolvedProjectConfig, +): void { + for (const override of buildCodexConfigOverrides(config)) { + args.push("--config", override); + } +} + +function buildCodexConfigOverrides(config: ResolvedProjectConfig): string[] { + const overrides: string[] = []; + const plugins = normalizeList(config.codex.plugins); + const skillsets = normalizeList(config.codex.skillsets); + + for (const plugin of plugins) { + const pluginKey = JSON.stringify(plugin); + overrides.push(`plugins.${pluginKey}.enabled=true`); + } + if (skillsets.length > 0) { + overrides.push(`skillsets=${toTomlStringArray(skillsets)}`); + } + for (const [rawKey, rawValue] of Object.entries( + config.codex.configOverrides ?? {}, + )) { + const key = rawKey.trim(); + const value = rawValue.trim(); + if (!key || !value) { + continue; + } + overrides.push(`${key}=${value}`); + } + + return overrides; +} + +function normalizeList(values: string[] | undefined): string[] { + if (!values) { + return []; + } + return values.map((value) => value.trim()).filter(Boolean); +} + +function toTomlStringArray(values: string[]): string { + return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`; +} diff --git a/src/types.ts b/src/types.ts index 276689d2..69d84fe4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,13 @@ export interface ProjectRuntimeConfig { implement?: string; reviewTest?: string; }; + plugins?: string[]; + skillsets?: string[]; + /** + * Raw Codex CLI -c overrides in key -> TOML value format, e.g. + * { "features.experimental": "true", "foo.bar": "\"baz\"" }. + */ + configOverrides?: Record; sandbox?: "read-only" | "workspace-write" | "danger-full-access"; codexHome?: string; }; diff --git a/tests/codex.test.ts b/tests/codex.test.ts index d0ff09db..a7e21d44 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -44,6 +44,11 @@ const config: ResolvedProjectConfig = { implement: "gpt-5.3-codex", reviewTest: "gpt-5.3-codex", }, + plugins: ["github@openai-curated", "linear@openai-curated"], + skillsets: ["piv-loop", "repo-defaults"], + configOverrides: { + "features.experimental_tools": "true", + }, sandbox: "workspace-write", codexHome: "/tmp/codex", }, @@ -59,6 +64,21 @@ describe("codex args", () => { expect(args).toContain("--output-last-message"); expect(args).toContain("/tmp/out.txt"); expect(args).toContain("--sandbox"); + expect(args).toEqual( + expect.arrayContaining([ + "--config", + 'plugins."github@openai-curated".enabled=true', + ]), + ); + expect(args).toEqual( + expect.arrayContaining([ + "--config", + 'skillsets=["piv-loop", "repo-defaults"]', + ]), + ); + expect(args).toEqual( + expect.arrayContaining(["--config", "features.experimental_tools=true"]), + ); }); it("supports stage model overrides", () => { @@ -109,6 +129,12 @@ describe("codex args", () => { ); expect(args).not.toContain("--sandbox"); expect(args).not.toContain("--cd"); + expect(args).toEqual( + expect.arrayContaining([ + "--config", + 'plugins."linear@openai-curated".enabled=true', + ]), + ); }); it("extracts session id from jsonl", () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index 099ac055..a03bf29d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -172,4 +172,49 @@ describe("loadConfig", () => { const configFromLegacyFlag = await loadConfig(process.cwd()); expect(configFromLegacyFlag.projects[0]?.codex.streamLogs).toBe(true); }); + + it("supports codex plugins and skillsets in project config", async () => { + const tempDir = await mkdtemp( + path.join(process.cwd(), ".tmp-config-test-"), + ); + await writeFile( + path.join(tempDir, "piv-loop.config.ts"), + [ + "export default {", + " codex: {", + " plugins: ['github@openai-curated'],", + " skillsets: ['default-skillset'],", + " configOverrides: {", + " 'features.root': 'true'", + " }", + " },", + " projects: [", + " {", + " id: 'default',", + " codex: {", + " plugins: ['linear@openai-curated'],", + " configOverrides: {", + " 'features.project': 'false'", + " }", + " }", + " }", + " ]", + "};", + "", + ].join("\n"), + ); + + try { + const config = await loadConfig(tempDir); + expect(config.projects[0]?.codex.plugins).toEqual([ + "linear@openai-curated", + ]); + expect(config.projects[0]?.codex.skillsets).toEqual(["default-skillset"]); + expect(config.projects[0]?.codex.configOverrides).toEqual({ + "features.project": "false", + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); });