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
130 changes: 130 additions & 0 deletions packages/cli/src/commands/codex-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import { splitEnvForCodex, buildCodexMcpBlock } from "./codex-config.js";
import type { MCPConfig } from "../core/hub-config.js";

describe("splitEnvForCodex", () => {
it("returns empty result for undefined env", () => {
expect(splitEnvForCodex(undefined)).toEqual({
literal: {},
forwarded: [],
warnings: [],
});
});

it("forwards a placeholder that matches its key", () => {
const result = splitEnvForCodex({ FIGMA_API_KEY: "${env:FIGMA_API_KEY}" });
expect(result.forwarded).toEqual(["FIGMA_API_KEY"]);
expect(result.literal).toEqual({});
expect(result.warnings).toEqual([]);
});

it("keeps non-placeholder values as literals", () => {
const result = splitEnvForCodex({ LOG_LEVEL: "info" });
expect(result.literal).toEqual({ LOG_LEVEL: "info" });
expect(result.forwarded).toEqual([]);
expect(result.warnings).toEqual([]);
});

it("warns when a placeholder references a different key", () => {
const result = splitEnvForCodex({ FOO: "${env:BAR}" });
expect(result.forwarded).toEqual([]);
expect(result.literal).toEqual({});
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain("FOO");
expect(result.warnings[0]).toContain("${env:BAR}");
});

it("forwards lowercase placeholder that matches its key", () => {
const result = splitEnvForCodex({ foo: "${env:foo}" });
expect(result.forwarded).toEqual(["foo"]);
expect(result.warnings).toEqual([]);
});

it("handles a mix of forwarded, literal and mismatched entries", () => {
const result = splitEnvForCodex({
TOKEN: "${env:TOKEN}",
LOG_LEVEL: "info",
MISMATCH: "${env:OTHER}",
});
expect(result.forwarded).toEqual(["TOKEN"]);
expect(result.literal).toEqual({ LOG_LEVEL: "info" });
expect(result.warnings).toHaveLength(1);
});
});

describe("buildCodexMcpBlock", () => {
const build = (mcp: MCPConfig) => buildCodexMcpBlock(mcp.name, mcp);

it("returns null when no url, image, command or package is set", () => {
expect(build({ name: "empty" })).toBeNull();
});

it("builds an http block from url", () => {
const result = build({ name: "figma", url: "https://mcp.figma.com/mcp" });
expect(result).not.toBeNull();
expect(result!.block).toContain("[mcp_servers.figma]");
expect(result!.block).toContain("url = 'https://mcp.figma.com/mcp'");
expect(result!.warnings).toEqual([]);
});

it("warns when a url MCP declares auth (Codex needs bearer_token_env_var)", () => {
const result = build({
name: "secure",
url: "https://mcp.example.com/mcp",
auth: "bearer",
});
expect(result).not.toBeNull();
expect(result!.warnings).toHaveLength(1);
expect(result!.warnings[0]).toContain("bearer_token_env_var");
});

it("builds an npx block from package and appends args", () => {
const result = build({
name: "framelink",
package: "figma-developer-mcp",
args: ["--stdio"],
});
expect(result!.block).toContain("command = 'npx'");
expect(result!.block).toContain("args = ['-y', 'figma-developer-mcp', '--stdio']");
});

it("builds a direct command block", () => {
const result = build({ name: "bee", command: "bee", args: ["mcp", "serve"] });
expect(result!.block).toContain("command = 'bee'");
expect(result!.block).toContain("args = ['mcp', 'serve']");
});

it("builds a docker block and forwards matching env placeholders", () => {
const result = build({
name: "signoz",
image: "signoz/signoz-mcp-server:latest",
env: { SIGNOZ_URL: "${env:SIGNOZ_URL}", LOG_LEVEL: "info" },
});
expect(result!.block).toContain("command = 'docker'");
expect(result!.block).toContain("'-e', 'SIGNOZ_URL'");
expect(result!.block).toContain("'-e', 'LOG_LEVEL=info'");
expect(result!.block).toContain("'signoz/signoz-mcp-server:latest'");
});

it("emits env_vars and a literal env table for a package MCP", () => {
const result = build({
name: "client-hub",
package: "@arvoretech/client-hub-mcp",
env: { CLIENT_HUB_API_TOKEN: "${env:CLIENT_HUB_API_TOKEN}", MODE: "prod" },
});
expect(result!.block).toContain("env_vars = ['CLIENT_HUB_API_TOKEN']");
expect(result!.block).toContain("[mcp_servers.client-hub.env]");
expect(result!.block).toContain("MODE = 'prod'");
});

it("propagates env warnings prefixed with the server name", () => {
const result = build({
name: "broken",
package: "some-mcp",
env: { FOO: "${env:BAR}" },
});
expect(result!.warnings).toHaveLength(1);
expect(result!.warnings[0]).toContain("broken");
expect(result!.warnings[0]).toContain("FOO");
});
});
105 changes: 105 additions & 0 deletions packages/cli/src/commands/codex-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { MCPConfig } from "../core/hub-config.js";

export function tomlString(value: string): string {
if (!value.includes("'") && !value.includes("\n")) {
return `'${value}'`;
}
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
return `"${escaped}"`;
}

export function tomlArray(values: string[]): string {
return `[${values.map((v) => tomlString(v)).join(", ")}]`;
}

const ENV_PLACEHOLDER = /^\$\{env:([A-Za-z0-9_]+)\}$/;
const ANY_ENV_PLACEHOLDER = /^\$\{env:.+\}$/;

/**
* Codex CLI stores MCP servers in `.codex/config.toml` using snake_case
* `[mcp_servers.<id>]` tables. Unlike Claude/Cursor, Codex does not expand
* `${env:VAR}` inside the `env` map — host env vars must be whitelisted via
* `env_vars` and are forwarded by name instead.
*
* A placeholder can only be forwarded when it references the exact same name as
* its key (`FOO: ${env:FOO}`). Mismatched (`FOO: ${env:BAR}`) or malformed
* placeholders cannot be represented in Codex's model, so they are reported as
* warnings instead of being written literally (which Codex would not expand).
*/
export function splitEnvForCodex(env: Record<string, string> | undefined): {
literal: Record<string, string>;
forwarded: string[];
warnings: string[];
} {
const literal: Record<string, string> = {};
const forwarded: string[] = [];
const warnings: string[] = [];
for (const [key, raw] of Object.entries(env ?? {})) {
const match = ENV_PLACEHOLDER.exec(raw);
if (match && match[1] === key) {
forwarded.push(key);
} else if (ANY_ENV_PLACEHOLDER.test(raw)) {
warnings.push(
`env "${key}": placeholder "${raw}" cannot be forwarded (Codex only supports \${env:${key}}); omitting it`
);
} else {
literal[key] = raw;
}
}
return { literal, forwarded, warnings };
}

export function buildCodexMcpBlock(
name: string,
mcp: MCPConfig
): { block: string; warnings: string[] } | null {
const lines: string[] = [`[mcp_servers.${name}]`];
const warnings: string[] = [];

if (mcp.url) {
lines.push(`url = ${tomlString(mcp.url)}`);
if (mcp.auth) {
warnings.push(
`"${name}": auth is configured but Codex needs a bearer_token_env_var; set it manually in .codex/config.toml`
);
}
return { block: lines.join("\n"), warnings };
}

const { literal, forwarded, warnings: envWarnings } = splitEnvForCodex(mcp.env);
warnings.push(...envWarnings.map((w) => `"${name}": ${w}`));

if (mcp.image) {
const args = ["run", "-i", "--rm"];
for (const [key, value] of Object.entries(mcp.env ?? {})) {
const match = ENV_PLACEHOLDER.exec(value);
if (match && match[1] === key) {
args.push("-e", key);
} else {
args.push("-e", `${key}=${value}`);
}
}
args.push(mcp.image);
lines.push(`command = 'docker'`, `args = ${tomlArray(args)}`);
} else if (mcp.command) {
lines.push(`command = ${tomlString(mcp.command)}`);
if (mcp.args?.length) lines.push(`args = ${tomlArray(mcp.args)}`);
} else if (mcp.package) {
lines.push(`command = 'npx'`, `args = ${tomlArray(["-y", mcp.package, ...(mcp.args || [])])}`);
} else {
return null;
}

if (forwarded.length) {
lines.push(`env_vars = ${tomlArray(forwarded)}`);
}
const literalKeys = Object.keys(literal);
if (literalKeys.length) {
lines.push("", `[mcp_servers.${name}.env]`);
for (const key of literalKeys) {
lines.push(`${key} = ${tomlString(literal[key])}`);
}
}

return { block: lines.join("\n"), warnings };
}
88 changes: 87 additions & 1 deletion packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { loadHubConfig, type HubConfig, type HookEntry, type MCPConfig } from ".
import { getSavedEditor, saveGenerateState, getKiroMode, saveKiroMode, readCache, writeCache, checkOutdated, type KiroMode } from "../core/hub-cache.js";
import { fetchRemoteSources } from "../core/design-sources.js";
import { loadPersona, buildPersonaEditorFile } from "./persona.js";
import { buildCodexMcpBlock } from "./codex-config.js";
import { buildCapabilitiesPrompt, resolvePiConfig } from "@arvoretech/hub-core";

const HUB_DOCS_URL = "https://hub.arvore.com.br/llms-full.txt";
Expand Down Expand Up @@ -914,6 +915,89 @@ async function generateClaudeCode(config: HubConfig, hubDir: string) {
console.log(chalk.green(" Generated .gitignore"));
}

async function generateCodex(config: HubConfig, hubDir: string) {
const codexDir = join(hubDir, ".codex");
await mkdir(codexDir, { recursive: true });

const orchestratorRule = buildOrchestratorRule(config);
const cleanedOrchestrator = orchestratorRule.replace(/^---[\s\S]*?---\n/m, "").trim();
const personaCodex = await loadPersona(hubDir);
await writeFile(join(hubDir, "AGENTS.md"), cleanedOrchestrator + "\n", "utf-8");
console.log(chalk.green(" Generated AGENTS.md"));
if (personaCodex) {
console.log(chalk.green(` Applied persona: ${personaCodex.name} (${personaCodex.role})`));
}

const skillsDir = resolve(hubDir, "skills");
const remoteSkillsCodex = getRemoteSkillNames(config);
try {
const skillFolders = await readdir(skillsDir);
const codexSkillsDir = join(codexDir, "skills");
await mkdir(codexSkillsDir, { recursive: true });
let count = 0;
for (const folder of skillFolders) {
if (remoteSkillsCodex.has(folder)) continue;
const skillFile = join(skillsDir, folder, "SKILL.md");
try {
await readFile(skillFile);
await cp(join(skillsDir, folder), join(codexSkillsDir, folder), { recursive: true });
count++;
} catch {
// skip
}
}
if (count > 0) {
console.log(chalk.green(` Copied ${count} skills to .codex/skills/`));
}
} catch {
// no skills dir
}

const codexSkillsDirForDocs = join(codexDir, "skills");
await mkdir(codexSkillsDirForDocs, { recursive: true });
await fetchHubDocsSkill(codexSkillsDirForDocs);
await syncRemoteSources(config, hubDir, join(codexDir, "skills"), join(codexDir, "steering"));

if (config.mcps?.length) {
const upstreamSet = getUpstreamNames(config.mcps);
const blocks: string[] = [
"# GENERATED by `hub generate -e codex` from hub.yaml — do not edit by hand.",
"#",
"# Codex only loads this file when the project is trusted:",
"# codex projects trust",
"# AGENTS.md and skills/ are read natively and do not need extra config here.",
"",
];
for (const mcp of config.mcps) {
if (upstreamSet.has(mcp.name)) continue;
let resolved: MCPConfig = mcp;
if (mcp.upstreams?.length) {
const { upstreamsJson, collectedEnv } = buildProxyUpstreams(mcp, config.mcps);
resolved = { ...mcp, env: { MCP_PROXY_UPSTREAMS: upstreamsJson, ...collectedEnv } };
}
const result = buildCodexMcpBlock(mcp.name, resolved);
if (result === null) {
console.log(chalk.yellow(` Skipping MCP "${mcp.name}": no url, image, or package configured`));
continue;
}
for (const warning of result.warnings) {
console.log(chalk.yellow(` ${warning}`));
}
blocks.push(result.block, "");
}
await writeFile(
join(codexDir, "config.toml"),
blocks.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n",
"utf-8"
);
console.log(chalk.green(" Generated .codex/config.toml"));
}

const gitignoreLines = buildGitignoreLines(config);
await writeManagedFile(join(hubDir, ".gitignore"), gitignoreLines);
console.log(chalk.green(" Generated .gitignore"));
}

async function generateKiro(config: HubConfig, hubDir: string) {
const kiroDir = join(hubDir, ".kiro");
const steeringDir = join(kiroDir, "steering");
Expand Down Expand Up @@ -1284,6 +1368,7 @@ function buildGitignoreLines(config: HubConfig): string[] {
".kiro/steering/persona.md",
".cursor/rules/persona.mdc",
".opencode/rules/persona.md",
".codex/rules/persona.md",
);

return lines;
Expand Down Expand Up @@ -1368,6 +1453,7 @@ export const generators: Record<string, Generator> = {
"claude-code": { name: "Claude Code", generate: generateClaudeCode },
kiro: { name: "Kiro", generate: generateKiro },
opencode: { name: "OpenCode", generate: generateOpenCode },
codex: { name: "Codex", generate: generateCodex },
};

async function resolveEditor(opts: { editor?: string; resetEditor?: boolean }): Promise<string> {
Expand Down Expand Up @@ -1412,7 +1498,7 @@ async function resolveEditor(opts: { editor?: string; resetEditor?: boolean }):

export const generateCommand = new Command("generate")
.description("Generate editor-specific configuration files from hub.yaml")
.option("-e, --editor <editor>", "Target editor (pi, cursor, claude-code, kiro, opencode)")
.option("-e, --editor <editor>", "Target editor (pi, cursor, claude-code, kiro, opencode, codex)")
.option("--reset-editor", "Reset saved editor preference and choose again")
.option("--check", "Check if generated configs are outdated (exit code 1 if outdated)")
.action(async (opts: { editor?: string; resetEditor?: boolean; check?: boolean }) => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/hub-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface MCPConfig {
description?: string;
instructions?: string;
package?: string;
command?: string;
args?: string[];
url?: string;
image?: string;
Expand Down
Loading