From 16d5ebaf5aa899d1013d9234682122d11e7850e4 Mon Sep 17 00:00:00 2001 From: Gabriel Gordon-Hall Date: Tue, 15 Sep 2026 06:42:04 +0100 Subject: [PATCH] fix: detect custom Codex homes independently of skill destination --- docs/specification.md | 3 ++- src/core/install.ts | 6 ++++-- tests/install.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/specification.md b/docs/specification.md index 231c3a1..72bce3a 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -230,7 +230,8 @@ failure to place skills never fails an install. A home install only targets hosts whose configuration directory already exists, and reports the rest under `skipped`; it never creates a directory for an absent -agent. Installation refuses symlinked or non-directory path segments, writes +agent. Codex detection uses `CODEX_HOME` when set, otherwise `~/.codex`; its skills +destination remains `~/.agents/skills` in either case. Installation refuses symlinked or non-directory path segments, writes skill files with mode `0644`, replaces only `context-tree-*` directories, and never modifies skills the package does not own. Codex and Pi share `.agents/skills`, so that directory is written once and reported for each host; diff --git a/src/core/install.ts b/src/core/install.ts index 5891375..64d0c00 100644 --- a/src/core/install.ts +++ b/src/core/install.ts @@ -98,8 +98,10 @@ function hostDestination( // Home installs only target hosts the user already has, so installing the CLI never // creates a configuration directory for an agent that is not present. let hostRoot = root; - for (const segment of directories.config) { - hostRoot = join(hostRoot, segment); + const codexHome = host === "codex" ? process.env.CODEX_HOME?.trim() : undefined; + const configPaths = codexHome ? [resolve(codexHome)] : directories.config; + for (const segment of configPaths) { + hostRoot = resolve(hostRoot, segment); const entry = lstatSync(hostRoot, { throwIfNoEntry: false }); if (entry === undefined) { return { reason: `${hostRoot} does not exist; install ${host} first, then run context-tree install.` }; diff --git a/tests/install.test.ts b/tests/install.test.ts index 1837dec..ef2ca41 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -11,7 +12,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { installSkills, uninstallSkills } from "../src/core/install.js"; const SKILLS = [ @@ -33,11 +34,46 @@ function workspace(): string { } afterEach(() => { + vi.unstubAllEnvs(); for (const root of roots) rmSync(root, { force: true, recursive: true }); roots.clear(); }); describe("skill installation", () => { + it.each([".codex", "codex-home"])("detects custom CODEX_HOME %s but installs into account HOME", (name) => { + const home = realpathSync(workspace()); + const customRoot = workspace(); + const codexHome = join(customRoot, name); + mkdirSync(codexHome); + vi.stubEnv("HOME", home); + vi.stubEnv("CODEX_HOME", codexHome); + + const result = installSkills({ hosts: ["codex"] }); + + expect(result.installed).toEqual([{ host: "codex", path: join(home, ".agents", "skills"), skills: SKILLS }]); + expect(result.skipped).toEqual([]); + expect(existsSync(join(home, ".codex"))).toBe(false); + expect(existsSync(join(customRoot, ".agents"))).toBe(false); + expect(existsSync(join(codexHome, "skills"))).toBe(false); + expect(existsSync(join(home, ".agents", "skills", "context-tree-read", "SKILL.md"))).toBe(true); + }); + + it.each(["missing", "file", "symlink"])("skips a %s CODEX_HOME even when the default exists", (kind) => { + const home = workspace(); + const codexHome = join(home, "custom-codex"); + mkdirSync(join(home, ".codex")); + if (kind === "file") writeFileSync(codexHome, "not a directory"); + if (kind === "symlink") symlinkSync(join(home, ".codex"), codexHome, "dir"); + vi.stubEnv("HOME", home); + vi.stubEnv("CODEX_HOME", codexHome); + + const result = installSkills({ hosts: ["codex"] }); + + expect(result.installed).toEqual([]); + expect(result.skipped).toEqual([{ host: "codex", reason: expect.stringContaining(codexHome) }]); + expect(existsSync(join(home, ".agents"))).toBe(false); + }); + it("installs every packaged skill for a named host below a project root", () => { const root = workspace(); const result = installSkills({ hosts: ["claude"], projectPath: root });