Browse Source

feat(installer): support project-local Codex installs (#1531) (#1551)

Codex CLI has a first-class project config layer — `.codex/config.toml`
is layer 4 of the loader stack, above the user config at layer 6
(`codex-rs/config/src/loader/README.md` in openai/codex), and it landed
in openai/codex#8354 on 2025-12-22. The CodexTarget's "Codex has no
project-local config concept" note was therefore never accurate, and
`supportsLocation('local') === false` made Codex the one agent that
forces a machine-wide MCP install.

`mcp_servers` is not on the project layer's denylist (which strips base
URLs, model providers, `notify`, profiles and otel — settings repo
contents shouldn't choose), so a project-scoped `[mcp_servers.codegraph]`
is honored.

- Path helpers take a `Location`: global keeps `~/.codex/config.toml` +
  `~/.codex/AGENTS.md`; local writes `<cwd>/.codex/config.toml` and the
  project-root `<cwd>/AGENTS.md` — the same split the gemini and
  opencode targets already use for their local layout.
- Drops the five `loc !== 'global'` early returns from detect, install,
  uninstall, printConfig and describePaths.
- Local install returns a note that Codex only applies a project layer
  in a project marked trusted; untrusted projects load the layer but
  leave it disabled, so a silent success would be misleading.
- Refreshes the two doc comments that used Codex as the example of a
  global-only target (now the Copilot CLI).

Tests: two new cases covering the local write layout, the trust note,
global config staying untouched, and local uninstall leaving the global
entry intact. Both fail against the previous implementation. The generic
per-target contract suite now also exercises codex at location=local.
Max Hsu 2 weeks ago
parent
commit
bc894802ff

+ 2 - 0
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
 
 - Import aliases defined in a shared TypeScript config are now picked up. Nx-style monorepos keep every `@scope/...` alias in a `tsconfig.base.json` that the root `tsconfig.json` only inherits through `extends`, so CodeGraph found no aliases at all and every cross-package import fell back to matching on name alone — which quietly attaches results to unrelated symbols that happen to share a name, exactly where a monorepo needs `codegraph_impact` and `codegraph_callers` to be right. Chains several configs deep, a config inherited from a package in `node_modules`, and a `baseUrl` declared in an inherited config are all followed now, and a `tsconfig.base.json` is read directly when the root `tsconfig.json` is only a project-references shell or isn't there at all. Re-index after upgrading. (#1534)

+ 36 - 0
__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');

+ 3 - 3
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;

+ 66 - 46
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:  `<cwd>/.codex/config.toml` + `<cwd>/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 };
 }

+ 4 - 4
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;