diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79..042d33f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - GitHub Copilot is now a supported agent: `codegraph install` can configure Copilot Chat in VS Code (`copilot-vscode`), the GitHub Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`). Installed Copilot surfaces are auto-detected like every other agent, existing MCP server entries in their config files are preserved, and `codegraph uninstall` reverses the setup cleanly. Restart VS Code or your JetBrains IDE after installing so Copilot picks up the server. +- Codex CLI can now be set up per project instead of only user-wide: `codegraph install --location=local` writes `./.codex/config.toml` and the CodeGraph block in your project's `AGENTS.md`, so CodeGraph is wired into that repo only rather than every Codex session on the machine. `codegraph uninstall --location=local` reverses it, and the global install is untouched either way. Codex only applies a project's config once you've marked the project trusted, so the installer says so after a local install. (#1531) + ### Fixes - C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 0d185a9d8..4ec3e5903 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -240,6 +240,42 @@ describe('Installer targets — partial-state idempotency', () => { expect(mdEntry?.action).toBe('updated'); }); + it('codex: local install writes ./.codex/config.toml and the project-root ./AGENTS.md block (#1531)', () => { + const codex = getTarget('codex')!; + const result = codex.install('local', { autoAllow: false }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + // macOS realpath shenanigans (/var vs /private/var) — suffix match. + expect(paths.some((p) => p.endsWith('/.codex/config.toml'))).toBe(true); + // AGENTS.md sits at the project root, NOT under .codex/ — that's the + // file Codex reads for repo instructions. + expect(paths.some((p) => p.endsWith('/AGENTS.md') && !p.includes('/.codex/'))).toBe(true); + + const toml = fs.readFileSync(path.join(process.cwd(), '.codex', 'config.toml'), 'utf-8'); + expect(toml).toContain('[mcp_servers.codegraph]'); + expect(fs.readFileSync(path.join(process.cwd(), 'AGENTS.md'), 'utf-8')).toContain('codegraph explore'); + + // The project layer is only applied in a trusted project, so say so + // instead of reporting a silent success. + expect(result.notes?.join(' ')).toMatch(/trusted/); + + // Global config is untouched by a local install. + expect(fs.existsSync(path.join(tmpHome, '.codex', 'config.toml'))).toBe(false); + }); + + it('codex: local uninstall reverses the local install and leaves the global entry alone (#1531)', () => { + const codex = getTarget('codex')!; + codex.install('global', { autoAllow: false }); + codex.install('local', { autoAllow: false }); + expect(codex.detect('local').alreadyConfigured).toBe(true); + + codex.uninstall('local'); + + expect(codex.detect('local').alreadyConfigured).toBe(false); + expect(codex.detect('global').alreadyConfigured).toBe(true); + expect(fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf-8')) + .toContain('[mcp_servers.codegraph]'); + }); + it('opencode: prefers .jsonc when both .json and .jsonc exist', () => { const opencode = getTarget('opencode')!; const dir = path.join(tmpHome, '.config', 'opencode'); diff --git a/src/installer/index.ts b/src/installer/index.ts index edeb4ac94..9a99ffe34 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -137,7 +137,7 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis } else if (useDefaults) { location = 'global'; } else { - // If every selected target is global-only (e.g. Codex), skip the + // If every selected target is global-only (e.g. the Copilot CLI), skip the // prompt and force user-wide — project-local would just produce // skip warnings. const allGlobalOnly = targets.every((t) => !t.supportsLocation('local')); @@ -328,8 +328,8 @@ export type UninstallStatus = 'removed' | 'not-configured' | 'unsupported'; * Per-target outcome of an uninstall sweep. `removed` means we deleted * at least one thing; `not-configured` means the agent had no codegraph * config at this location (nothing to do); `unsupported` means the - * agent has no config concept for this location (e.g. Codex is - * global-only, so a `local` uninstall skips it). + * agent has no config concept for this location (e.g. the Copilot CLI + * is global-only, so a `local` uninstall skips it). */ export interface UninstallReport { id: TargetId; diff --git a/src/installer/targets/codex.ts b/src/installer/targets/codex.ts index 0a2b8c9c8..d5e361ce3 100644 --- a/src/installer/targets/codex.ts +++ b/src/installer/targets/codex.ts @@ -1,15 +1,29 @@ /** * OpenAI Codex CLI target. * - * - MCP server entry to `~/.codex/config.toml` as the dotted-key - * table `[mcp_servers.codegraph]`. TOML — not JSON — handled by - * the narrow serializer in `./toml.ts`. - * - Instructions to `~/.codex/AGENTS.md`. + * - MCP server entry to `config.toml` as the dotted-key table + * `[mcp_servers.codegraph]`. TOML — not JSON — handled by the + * narrow serializer in `./toml.ts`. + * - Instructions to `AGENTS.md`. * - * Codex CLI as of 2026-05 has no project-local config concept — - * everything lives under `~/.codex/`. `supportsLocation('local')` - * returns false; the orchestrator skips Codex when the user picks - * the local install location. + * Both locations are supported (#1531): + * - global: `~/.codex/config.toml` + `~/.codex/AGENTS.md` + * - local: `/.codex/config.toml` + `/AGENTS.md` + * + * Codex has a first-class project config layer: `.codex/config.toml` + * is layer 4 of the loader's stack, above the user config (layer 6), + * merged recursively top-over-bottom + * (`codex-rs/config/src/loader/README.md` in openai/codex). It landed + * in openai/codex#8354 (2025-12-22), so the "Codex has no + * project-local config" note this file used to carry was never + * accurate. The project layer strips a denylist of settings that + * repo contents shouldn't get to choose (base URLs, model providers, + * `notify`, profiles, otel — `loader/mod.rs`), and `mcp_servers` is + * NOT on it, so a project-scoped `[mcp_servers.codegraph]` is honored. + * + * Caveat surfaced as an install note: project layers are "loaded but + * disabled when untrusted," so a local install only takes effect in a + * project the user has marked trusted. * * No permissions concept. */ @@ -38,14 +52,31 @@ import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml'; const TOML_HEADER = 'mcp_servers.codegraph'; -function configDir(): string { - return path.join(os.homedir(), '.codex'); +function configDir(loc: Location): string { + return loc === 'global' + ? path.join(os.homedir(), '.codex') + : path.join(process.cwd(), '.codex'); +} +function tomlConfigPath(loc: Location): string { + return path.join(configDir(loc), 'config.toml'); } -function tomlConfigPath(): string { - return path.join(configDir(), 'config.toml'); +function instructionsPath(loc: Location): string { + // Global AGENTS.md lives under ~/.codex/; project-local AGENTS.md + // lives at the project root (NOT under .codex/) — that's the file + // Codex reads for repo instructions, and it matches the local + // layout the opencode and gemini targets already use. + return loc === 'global' + ? path.join(configDir('global'), 'AGENTS.md') + : path.join(process.cwd(), 'AGENTS.md'); } -function instructionsPath(): string { - return path.join(configDir(), 'AGENTS.md'); + +/** + * Project layers are "loaded but disabled when untrusted" (openai/codex + * `loader/mod.rs`), so a local install can be written correctly and + * still do nothing. Say so rather than reporting silent success. + */ +function trustNote(): string { + return `Codex applies ${tomlConfigPath('local')} only in a project marked trusted — otherwise the layer is loaded but disabled. Trust this project in Codex to activate it.`; } class CodexTarget implements AgentTarget { @@ -53,15 +84,12 @@ class CodexTarget implements AgentTarget { readonly displayName = 'Codex CLI'; readonly docsUrl = 'https://github.com/openai/codex'; - supportsLocation(loc: Location): boolean { - return loc === 'global'; + supportsLocation(_loc: Location): boolean { + return true; } detect(loc: Location): DetectionResult { - if (loc !== 'global') { - return { installed: false, alreadyConfigured: false }; - } - const tomlPath = tomlConfigPath(); + const tomlPath = tomlConfigPath(loc); let alreadyConfigured = false; if (fs.existsSync(tomlPath)) { try { @@ -69,34 +97,30 @@ class CodexTarget implements AgentTarget { alreadyConfigured = content.includes(`[${TOML_HEADER}]`); } catch { /* ignore */ } } - const installed = fs.existsSync(configDir()); + // Global: ~/.codex/ existing means Codex has run here. Local: the + // project only counts as "Codex-enabled" once it actually has a + // .codex/ dir or config file of its own. + const installed = fs.existsSync(configDir(loc)) || fs.existsSync(tomlPath); return { installed, alreadyConfigured, configPath: tomlPath }; } install(loc: Location, _opts: InstallOptions): WriteResult { - if (loc !== 'global') { - return { - files: [], - notes: ['Codex CLI has no project-local config — re-run with --location=global to install.'], - }; - } const files: WriteResult['files'] = []; - files.push(writeMcpEntry()); + files.push(writeMcpEntry(loc)); // AGENTS.md gets the short marker-fenced CodeGraph block (#704): // subagents and non-MCP harnesses read AGENTS.md but never the MCP // initialize instructions. Upsert self-heals a stale pre-#529 block. - files.push(upsertInstructionsEntry(instructionsPath())); + files.push(upsertInstructionsEntry(instructionsPath(loc))); - return { files }; + return loc === 'local' ? { files, notes: [trustNote()] } : { files }; } uninstall(loc: Location): WriteResult { - if (loc !== 'global') return { files: [] }; const files: WriteResult['files'] = []; - const tomlPath = tomlConfigPath(); + const tomlPath = tomlConfigPath(loc); if (fs.existsSync(tomlPath)) { const content = fs.readFileSync(tomlPath, 'utf-8'); const { content: nextContent, action } = removeTomlTable(content, TOML_HEADER); @@ -114,22 +138,18 @@ class CodexTarget implements AgentTarget { files.push({ path: tomlPath, action: 'not-found' }); } - files.push(removeInstructionsEntry()); + files.push(removeInstructionsEntry(loc)); return { files }; } printConfig(loc: Location): string { - if (loc !== 'global') { - return '# Codex CLI has no project-local config — use --location=global.\n'; - } const block = buildCodegraphBlock(); - return `# Add to ${tomlConfigPath()}\n\n${block}\n`; + return `# Add to ${tomlConfigPath(loc)}\n\n${block}\n`; } describePaths(loc: Location): string[] { - if (loc !== 'global') return []; - return [tomlConfigPath(), instructionsPath()]; + return [tomlConfigPath(loc), instructionsPath(loc)]; } } @@ -141,8 +161,8 @@ function buildCodegraphBlock(): string { }); } -function writeMcpEntry(): WriteResult['files'][number] { - const file = tomlConfigPath(); +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = tomlConfigPath(loc); const dir = path.dirname(file); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -162,12 +182,12 @@ function writeMcpEntry(): WriteResult['files'][number] { } /** - * Strip the marker-delimited CodeGraph block from `~/.codex/AGENTS.md` - * if a prior install wrote one. Used by both install (self-heal on - * upgrade) and uninstall — see issue #529. + * Strip the marker-delimited CodeGraph block from this location's + * AGENTS.md if a prior install wrote one. Used by both install + * (self-heal on upgrade) and uninstall — see issue #529. */ -function removeInstructionsEntry(): WriteResult['files'][number] { - const file = instructionsPath(); +function removeInstructionsEntry(loc: Location): WriteResult['files'][number] { + const file = instructionsPath(loc); const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); return { path: file, action }; } diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 022ab28e8..d93680573 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -87,10 +87,10 @@ export interface AgentTarget { /** * Whether this target supports the given install location. * - * Some agents (Codex CLI as of 2026-05) have no project-local - * config concept — only a single `~/.codex/` dir. Returning false - * for an unsupported (target, location) pair lets the orchestrator - * skip cleanly with a clear message. + * Some agents (GitHub Copilot CLI, the Copilot JetBrains plugin) + * have no project-local config concept — only a single per-user + * config dir. Returning false for an unsupported (target, location) + * pair lets the orchestrator skip cleanly with a clear message. */ supportsLocation(loc: Location): boolean; detect(loc: Location): DetectionResult;