Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions profiles/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ env:
| `skills.plugins` | array of strings (Claude Code plugin names) | no | `[]` | Resolved from `~/.claude/plugins/<name>/skills/`. Targets are namespaced as `<plugin>:<skill>`. |
| `mcps` | array of strings or `{id, agents?, when?}` objects | no | `[]` | Each id must match a key in `cue/mcps/configs/claude.sanitized.json` (or the codex counterpart). Object form adds optional `agents:` scoping and a `when:` activation gate — see "Conditional activation (`when:`)" below. |
| `env` | map<string, string> | no | `{}` | Plain string values. Placeholders like `"${HOSTINGER_API_TOKEN}"` are substituted at materialize-time. |
| `codex_config` | map<string, any> | no | `{}` | Extra keys merged into the Codex runtime's `config.toml` next to this profile's MCP servers. cue repoints `CODEX_HOME` at the materialized runtime, so the user's own `~/.codex/config.toml` is **never read** — `sandbox_mode`, `sandbox_workspace_write`, `approval_policy`, `shell_environment_policy` and friends have to come through here. Emitted verbatim as TOML; ignored for non-Codex agents. See "codex_config example" below. |
| `rules` | array of strings | no | `[]` | Markdown rule files under `resources/rules/` (or absolute paths). Symlinked into `<runtime>/rules/` and indexed in CLAUDE.md — Claude reads on demand, no full-body inline. |
| `commands` | array of strings | no | `[]` | Slash-command markdown files under `resources/commands/`. Symlinked into `<runtime>/commands/` so the user can invoke `/<name>`. Listed in CLAUDE.md's "Available Commands" section. |
| `hooks` | array of strings | no | `[]` | Hook bundle JSON files under `resources/hooks/`. Each declares `{ "hooks": { "PreToolUse": [...], "Stop": [...], ... } }` — merged into `settings.json` so hooks run per Claude Code's lifecycle. Sibling `.sh`/`.py` scripts are symlinked into `<runtime>/hooks/` and invoked via `${CLAUDE_CONFIG_DIR}/hooks/...`. |
Expand Down Expand Up @@ -68,6 +69,49 @@ time and reported as `E3` by `cue validate`.
Inheritance merges all three with `concat + dedupe`; a child can't remove a
parent's entry — fork the parent if you need a smaller set.

### codex_config example

`cue launch codex` points `CODEX_HOME` at the materialized runtime, so Codex reads
`<runtime>/codex/config.toml` and never your own `~/.codex/config.toml`. Anything
Codex needs beyond MCP servers goes here:

```yaml
# profiles/my-browser-stack/profile.yaml
name: my-browser-stack
codex_config:
sandbox_mode: "workspace-write"
approval_policy: "never"
sandbox_workspace_write:
writable_roots:
- "/home/me/.local/share/ego-lite-linux"
- "/home/me/.local/state/ego-lite-linux"
network_access: true
```

renders to:

```toml
sandbox_mode = "workspace-write"
approval_policy = "never"

[sandbox_workspace_write]
writable_roots = ["/home/me/.local/share/ego-lite-linux", "/home/me/.local/state/ego-lite-linux"]
network_access = true

[mcp_servers.…]
```

Bare keys are emitted before any `[table]` header, because TOML binds every key
after a header to that table — `sandbox_mode` written after `[mcp_servers.foo]`
would silently become `mcp_servers.foo.sandbox_mode`.

Merging is **two levels deep**, later wins: in `a+b`, a `b` that sets only
`sandbox_workspace_write.network_access` keeps `a`'s `writable_roots` rather than
replacing the table. Deeper than two levels, a value replaces wholesale.

Values are emitted verbatim — cue does not validate them against Codex's own
schema, so a typo here surfaces as Codex ignoring the key.

## Conditional activation (`when:`)

Both `skills.local` entries and `mcps` entries accept an object form with a
Expand Down Expand Up @@ -174,6 +218,9 @@ mcps:

- **arrays** (`skills.local`, `skills.npx`, `skills.plugins`, `mcps`, `agents`) — concat parent then child, dedupe by identity (string for plain arrays; `repo` for `NpxSkillRef`)
- **objects** (`skills`, `env`) — child keys override parent keys; nested arrays merge per the rule above
- **`codex_config`** — same, but one level deeper: a child that sets only
`sandbox_workspace_write.network_access` keeps the parent's sibling keys in
that table instead of replacing it
- **scalars** (`name`, `description`) — child overrides parent

**Constraints:**
Expand All @@ -197,6 +244,7 @@ The `name:` field must equal the directory name. Two profiles with the same
| `skills.plugins`| `[]` |
| `mcps` | `[]` |
| `env` | `{}` |
| `codex_config` | `{}` |

A profile with only `name` and `description` is legal but useless — it
materializes an empty workspace. The linter flags this as `W5` (vacuous
Expand Down
8 changes: 8 additions & 0 deletions profiles/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ export interface Profile {
mcps?: MCPRef[];
plugins?: PluginRef[];
env?: Record<string, string>;
// Extra keys merged into the Codex runtime's config.toml alongside the
// profile's MCP servers. cue repoints CODEX_HOME at the materialized runtime,
// so a user's own ~/.codex/config.toml is never read — anything Codex needs
// beyond MCP servers (sandbox_mode, sandbox_workspace_write, approval_policy,
// shell_environment_policy) has to come through here. Values are emitted
// verbatim as TOML. Ignored for non-Codex agents. Shallow merge, later wins.
codex_config?: Record<string, unknown>;
rules?: string[];
commands?: string[];
hooks?: string[];
Expand Down Expand Up @@ -187,6 +194,7 @@ export interface ResolvedProfile extends Omit<Profile, "skills" | "mcps" | "plug
mcps: ResolvedMCP[];
plugins: ResolvedPlugin[];
env: Record<string, string>;
codexConfig: Record<string, unknown>;
rules: string[];
commands: string[];
hooks: string[];
Expand Down
4 changes: 4 additions & 0 deletions profiles/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@
"additionalProperties": { "type": "string" },
"description": "Env vars set when this profile is materialized; values may contain ${VAR} placeholders."
},
"codex_config": {
"type": "object",
"description": "Extra keys merged into the Codex runtime's config.toml alongside this profile's MCP servers. cue repoints CODEX_HOME at the materialized runtime, so the user's own ~/.codex/config.toml is never read — put sandbox_mode, sandbox_workspace_write, approval_policy, shell_environment_policy and similar here. Emitted verbatim as TOML; ignored for non-Codex agents."
},
"rules": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
Expand Down
60 changes: 60 additions & 0 deletions src/lib/profile-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,66 @@ describe("loadProfile (composite)", () => {
expect(merged.inherits).toBeUndefined();
});

test("codex_config merges two levels deep across a composite", async () => {
// The failure this guards: beta sets only network_access. A shallow merge
// would replace alpha's whole sandbox_workspace_write table and silently
// drop writable_roots — invisible until Codex fails to launch a browser.
await writeProfile(
"alpha",
[
"name: alpha",
"description: Alpha",
"codex_config:",
' sandbox_mode: "workspace-write"',
" sandbox_workspace_write:",
" writable_roots:",
' - "/home/u/.local/share/ego-lite-linux"',
].join("\n"),
);
await writeProfile(
"beta",
[
"name: beta",
"description: Beta",
"codex_config:",
' approval_policy: "never"',
" sandbox_workspace_write:",
" network_access: true",
].join("\n"),
);

const merged = await loadProfile("alpha+beta");

expect(merged.codexConfig).toEqual({
sandbox_mode: "workspace-write",
approval_policy: "never",
sandbox_workspace_write: {
writable_roots: ["/home/u/.local/share/ego-lite-linux"],
network_access: true,
},
});
});

test("codex_config collision is later-wins at the leaf", async () => {
await writeProfile(
"alpha",
["name: alpha", "description: Alpha", "codex_config:", ' sandbox_mode: "read-only"'].join("\n"),
);
await writeProfile(
"beta",
["name: beta", "description: Beta", "codex_config:", ' sandbox_mode: "workspace-write"'].join("\n"),
);

const merged = await loadProfile("alpha+beta");
expect(merged.codexConfig.sandbox_mode).toBe("workspace-write");
});

test("profiles without codex_config resolve to an empty object", async () => {
await writeProfile("alpha", "name: alpha\ndescription: Alpha\n");
const merged = await loadProfile("alpha");
expect(merged.codexConfig).toEqual({});
});

test("missing component throws ProfileNotFound for that part", async () => {
await writeProfile(
"alpha",
Expand Down
35 changes: 35 additions & 0 deletions src/lib/profile-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,36 @@ function mergeEnv(
return { ...(parent ?? {}), ...(child ?? {}) };
}

/**
* Merge Codex config blocks, child winning on collision.
*
* Two levels deep, unlike `mergeEnv`. Codex config is tables, not flat strings:
* a plain shallow merge would let a child that sets only
* `sandbox_workspace_write.network_access` silently delete a parent's
* `writable_roots`. Composite selectors here run 10+ profiles wide, so that
* drop would be both likely and invisible.
*
* Nesting stops at two levels — a table's table is replaced wholesale, which
* keeps the rule easy to state and matches how flat Codex's own config is.
*/
function mergeCodexConfig(
parent: Record<string, unknown> | undefined,
child: Record<string, unknown> | undefined,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...(parent ?? {}) };
for (const [key, childVal] of Object.entries(child ?? {})) {
const parentVal = out[key];
out[key] = isPlainObject(parentVal) && isPlainObject(childVal)
? { ...parentVal, ...childVal }
: childVal;
}
return out;
}

function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

const DEFAULT_AGENTS: ResolvedProfile["agents"] = ["claude-code", "codex"];

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -486,6 +516,7 @@ function foldChain(chain: Profile[]): ResolvedProfile {
child.plugins?.map(normalizePluginRef),
),
env: mergeEnv(acc.env, child.env),
codexConfig: mergeCodexConfig(acc.codexConfig, child.codex_config),
rules: dedupePrimitiveArray(acc.rules, child.rules),
commands: dedupePrimitiveArray(acc.commands, child.commands),
hooks: dedupePrimitiveArray(acc.hooks, child.hooks),
Expand Down Expand Up @@ -538,6 +569,7 @@ function normalizeToResolved(p: Profile, chain: string[]): ResolvedProfile {
mcps: (p.mcps ?? []).map(normalizeMCPRef),
plugins: (p.plugins ?? []).map(normalizePluginRef),
env: { ...(p.env ?? {}) },
codexConfig: { ...(p.codex_config ?? {}) },
rules: [...(p.rules ?? [])],
commands: [...(p.commands ?? [])],
hooks: [...(p.hooks ?? [])],
Expand Down Expand Up @@ -607,6 +639,7 @@ export function isCompositeSelector(selector: string): boolean {
* - `inherits`: dropped (each component is already flattened)
* - `skills`/`mcps`/`plugins`: union by id, later wins on collision
* - `env`: shallow merge, later wins on collision
* - `codexConfig`: two-level merge, later wins on collision
* - `rules`/`commands`/`hooks`/`playbooks`/`qualityGates`/`evals`: dedupe-concat
* - `persona`: concatenated with `## <profile name>` headers so both
* personas stay legible. Empty personas are skipped.
Expand Down Expand Up @@ -637,6 +670,7 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf
mcps: [...head.mcps],
plugins: [...head.plugins],
env: { ...head.env },
codexConfig: { ...head.codexConfig },
rules: [...head.rules],
commands: [...head.commands],
hooks: [...head.hooks],
Expand Down Expand Up @@ -681,6 +715,7 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf
mcps: mergeObjectRefs<ResolvedMCP>(acc.mcps, next.mcps),
plugins: mergeObjectRefs<ResolvedPlugin>(acc.plugins, next.plugins),
env: mergeEnv(acc.env, next.env),
codexConfig: mergeCodexConfig(acc.codexConfig, next.codexConfig),
rules: dedupePrimitiveArray(acc.rules, next.rules),
commands: dedupePrimitiveArray(acc.commands, next.commands),
hooks: dedupePrimitiveArray(acc.hooks, next.hooks),
Expand Down
68 changes: 68 additions & 0 deletions src/lib/runtime-materializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const sampleProfile: ResolvedProfile = {
mcps: [{ id: "claude-mem" }],
plugins: [{ id: "frontend-design@claude-plugins-official" }],
env: {},
codexConfig: {},
inheritanceChain: ["test-frontend"],
};

Expand Down Expand Up @@ -60,6 +61,73 @@ describe("materializeRuntime", () => {
expect(toml).not.toContain('"GOOGLE_PROJECT_ID":"my-project"');
});

test("codex_config lands in config.toml with bare keys before any table", async () => {
const profile: ResolvedProfile = {
...sampleProfile,
name: "test-codex-config",
agents: ["codex"],
mcps: [{ id: "google-ads-mcp" }],
codexConfig: {
sandbox_mode: "workspace-write",
approval_policy: "never",
sandbox_workspace_write: {
writable_roots: ["/home/u/.local/share/ego-lite-linux"],
network_access: true,
},
},
inheritanceChain: ["test-codex-config"],
};
const out = await materializeRuntime({
profile,
agent: "codex",
runtimeRoot: join(root, "runtime"),
skillSourceLookup: async (id) => `/fake/skills/${id}`,
mcpRegistry: { "google-ads-mcp": { command: "pipx", args: ["run", "google-ads-mcp"] } },
userClaudeMd: "",
});

const toml = await readFile(join(out.runtimeDir, "config.toml"), "utf8");

// Ordering is the whole point: a bare key emitted after a [table] header
// binds to that table. `sandbox_mode` must not become
// `mcp_servers.google-ads-mcp.sandbox_mode`.
expect(toml.indexOf("sandbox_mode")).toBeLessThan(toml.indexOf("["));
expect(toml.indexOf("approval_policy")).toBeLessThan(toml.indexOf("["));

// Parse it for real rather than trusting substring checks.
const parsed = Bun.TOML.parse(toml) as Record<string, any>;
expect(parsed.sandbox_mode).toBe("workspace-write");
expect(parsed.approval_policy).toBe("never");
expect(parsed.sandbox_workspace_write).toEqual({
writable_roots: ["/home/u/.local/share/ego-lite-linux"],
network_access: true,
});
// MCP servers still land, unchanged.
expect(parsed.mcp_servers["google-ads-mcp"].command).toBe("pipx");
});

test("empty codex_config leaves config.toml as MCP servers only", async () => {
const profile: ResolvedProfile = {
...sampleProfile,
name: "test-codex-noconfig",
agents: ["codex"],
mcps: [{ id: "claude-mem" }],
inheritanceChain: ["test-codex-noconfig"],
};
const out = await materializeRuntime({
profile,
agent: "codex",
runtimeRoot: join(root, "runtime"),
skillSourceLookup: async (id) => `/fake/skills/${id}`,
mcpRegistry: { "claude-mem": { command: "claude-mem", args: [] } },
userClaudeMd: "",
});

const toml = await readFile(join(out.runtimeDir, "config.toml"), "utf8");
expect(toml.trimStart().startsWith("[mcp_servers.")).toBe(true);
expect(Object.keys(Bun.TOML.parse(toml))).toEqual(["mcp_servers"]);
});

test("creates runtime dir with hash and settings.json", async () => {
const out = await materializeRuntime({
profile: sampleProfile,
Expand Down
38 changes: 36 additions & 2 deletions src/lib/runtime-materializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,10 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise<Mate
await writeFile(join(tmpDir, "settings.json"), merged + "\n");
} else {
// Codex equivalent — write config.toml from registry. Caller pre-renders to TOML.
await writeFile(join(tmpDir, "config.toml"), tomlRender({ mcp_servers: mcpServers }));
await writeFile(
join(tmpDir, "config.toml"),
tomlRender({ mcp_servers: mcpServers, extra: profile.codexConfig }),
);
}

// 3. CLAUDE.md with stamp + role identity
Expand Down Expand Up @@ -1538,8 +1541,31 @@ function tomlValue(value: unknown): string {
throw new TypeError(`Unsupported TOML value: ${String(value)}`);
}

function tomlRender(obj: { mcp_servers: Record<string, unknown> }): string {
function tomlRender(obj: {
mcp_servers: Record<string, unknown>;
extra?: Record<string, unknown>;
}): string {
const out: string[] = [];

// Profile `codex_config` first, and within it bare keys before any table
// header. TOML binds every key after `[table]` to that table, so emitting
// `sandbox_mode` after `[mcp_servers.foo]` would silently make it
// `mcp_servers.foo.sandbox_mode` and Codex would ignore it.
const extra = obj.extra ?? {};
const scalars = Object.entries(extra).filter(([, v]) => !isTomlTable(v));
const tables = Object.entries(extra).filter(([, v]) => isTomlTable(v));

for (const [k, v] of scalars) out.push(`${k} = ${tomlValue(v)}`);
if (scalars.length > 0) out.push("");

for (const [name, val] of tables) {
out.push(`[${name}]`);
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
out.push(`${k} = ${tomlValue(v)}`);
}
out.push("");
}

for (const [id, val] of Object.entries(obj.mcp_servers)) {
out.push(`[mcp_servers.${id}]`);
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
Expand All @@ -1550,6 +1576,14 @@ function tomlRender(obj: { mcp_servers: Record<string, unknown> }): string {
return out.join("\n");
}

/**
* True for values that must render as a `[table]` header rather than a bare
* key. Arrays are inline TOML values, so they stay scalars here.
*/
function isTomlTable(v: unknown): boolean {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

// ---------------------------------------------------------------------------
// #8: Warm-start — summarize last session for this profile
// ---------------------------------------------------------------------------
Expand Down
Loading