diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f15be2b2..133b42e13 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Fixed + +- Fixed Claude Code user discovery ignoring `CLAUDE_CONFIG_DIR` for configuration, plugins, MCP servers, and imported sessions ([#8436](https://github.com/can1357/oh-my-pi/issues/8436)). + ### Fixed diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index fc2b34332..4032ff22f 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { CONFIG_DIR_NAME, getConfigAgentDirName, getProjectDir } from "@oh-my-pi/pi-utils"; +import { resolveClaudePaths } from "./config/claude-paths"; import { expandTilde } from "./tools/path-utils"; export * from "./config/config-file"; @@ -76,12 +77,12 @@ export function getChangelogPath(): string | undefined { // ============================================================================= /** - * Config directory bases in priority order (highest first). - * User-level: ~/.omp/agent, ~/.claude, ~/.codex, ~/.gemini + * User-level: ~/.omp/agent, Claude's active config directory, ~/.codex, ~/.gemini * Project-level: .omp, .claude, .codex, .gemini */ const USER_CONFIG_BASES = priorityList.map(({ dir, globalAgentDir }) => ({ - base: () => path.join(os.homedir(), globalAgentDir ? globalAgentDir() : dir), + base: () => + dir === ".claude" ? resolveClaudePaths().configDir : path.join(os.homedir(), globalAgentDir?.() ?? dir), name: dir, })); diff --git a/packages/coding-agent/src/config/claude-paths.ts b/packages/coding-agent/src/config/claude-paths.ts new file mode 100644 index 000000000..fd0056dd1 --- /dev/null +++ b/packages/coding-agent/src/config/claude-paths.ts @@ -0,0 +1,18 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +/** Paths to Claude Code's user data and configuration file. */ +export interface ClaudePaths { + configDir: string; + configFile: string; +} + +/** Resolves Claude Code's user paths, honoring `CLAUDE_CONFIG_DIR`. */ +export function resolveClaudePaths(home: string = os.homedir()): ClaudePaths { + const override = process.env.CLAUDE_CONFIG_DIR?.trim(); + if (override) { + const configDir = path.resolve(override); + return { configDir, configFile: path.join(configDir, ".claude.json") }; + } + return { configDir: path.join(home, ".claude"), configFile: path.join(home, ".claude.json") }; +} diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index df7f16881..6786c77b7 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -18,6 +18,7 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt"; import { type CustomTool, toolCapability } from "../capability/tool"; import type { LoadContext, LoadResult } from "../capability/types"; +import { resolveClaudePaths } from "../config/claude-paths"; import { settings } from "../config/settings"; import { calculateDepth, @@ -34,11 +35,10 @@ const DISPLAY_NAME = "Claude Code"; const PRIORITY = 80; const CONFIG_DIR = ".claude"; -/** - * Get user-level .claude path. - */ +/** Get the active user-level Claude Code directory. */ function getUserClaude(ctx: LoadContext): string { - return path.join(ctx.home, CONFIG_DIR); + const { configDir } = resolveClaudePaths(ctx.home); + return configDir; } /** @@ -60,8 +60,7 @@ async function loadMCPServers(ctx: LoadContext): Promise> const items: MCPServer[] = []; const warnings: string[] = []; - const userBase = getUserClaude(ctx); - const userClaudeJson = path.join(ctx.home, ".claude.json"); + const { configDir: userBase, configFile: userClaudeJson } = resolveClaudePaths(ctx.home); const userMcpJson = path.join(userBase, "mcp.json"); const projectBase = path.join(ctx.cwd, CONFIG_DIR); diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 5f13e6bd9..01456ca83 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -16,6 +16,7 @@ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../ca import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule"; import type { Skill, SkillFrontmatter } from "../capability/skill"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; +import { resolveClaudePaths } from "../config/claude-paths"; import type { MCPRequestIdFormat } from "../mcp/types"; import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking"; import { normalizeToolNames } from "../tools/builtin-names"; @@ -90,10 +91,9 @@ export type SourceId = keyof typeof SOURCE_PATHS; */ export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null { // Native user config is profile-scoped via getAgentDir() (the active profile's - // agent dir), matching builtin.ts and getMCPConfigPath("user"). External tools - // (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep - // resolving against ctx.home below. + // agent dir), matching builtin.ts and getMCPConfigPath("user"). if (source === "native") return path.join(getAgentDir(), subpath); + if (source === "claude") return path.join(resolveClaudePaths(ctx.home).configDir, subpath); const paths = SOURCE_PATHS[source]; if (!paths.userAgent) return null; return path.join(ctx.home, paths.userAgent, subpath); @@ -903,20 +903,21 @@ export function registerPluginCacheInvalidator(invalidator: () => void): void { } /** - * List all installed Claude Code plugin roots from the plugin cache. - * Reads ~/.claude/plugins/installed_plugins.json and ~/.omp/plugins/installed_plugins.json, - * and optionally the nearest project-scoped registry resolved from `cwd`. + * List all installed Claude Code plugin roots from its active plugin cache and + * ~/.omp/plugins/installed_plugins.json, plus the nearest project registry when present. * - * Results are cached per home, project registry, and canonical active project. + * Results are cached per Claude and OMP config directories, project registry, and canonical active project. */ export async function listClaudePluginRoots( home: string, cwd?: string, ): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> { + const claudeConfigDir = resolveClaudePaths(home).configDir; + const ompRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json"); const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null; const projectRoot = resolvedProjectPath ? path.dirname(path.dirname(path.dirname(resolvedProjectPath))) : cwd; const activeClaudeProjectPath = projectRoot ? await canonicalClaudeProjectPath(projectRoot) : null; - const cacheKey = `${home}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`; + const cacheKey = `${claudeConfigDir}:${ompRegistryPath}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`; const cached = pluginRootsCache.get(cacheKey); if (cached) return cached; @@ -926,7 +927,7 @@ export async function listClaudePluginRoots( const canonicalClaudeProjectPaths = new Map(); // ── Claude Code registry ────────────────────────────────────────────────── - const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json"); + const registryPath = path.join(claudeConfigDir, "plugins", "installed_plugins.json"); const content = await readFile(registryPath); if (content) { @@ -983,7 +984,7 @@ export async function listClaudePluginRoots( // In production `home` is `os.homedir()`, so `getPluginsDir(home)` resolves to the // same XDG-aware path the marketplace writer uses (reads and writes always agree). // Tests pass a temp dir, which short-circuits the resolver for deterministic isolation. - const ompRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json"); + // Computed before the cache lookup because isolated SDK homes select distinct OMP registries. const ompContent = await readFile(ompRegistryPath); if (ompContent) { const ompRegistry = parseClaudePluginsRegistry(ompContent); @@ -1107,7 +1108,7 @@ export function clearClaudePluginRootsCache(): void { * installing/uninstalling/enabling/disabling plugins. */ export function clearPluginRootsAndCaches(extraPaths?: readonly string[]): void { - invalidateFsCache(path.join(os.homedir(), ".claude", "plugins", "installed_plugins.json")); + invalidateFsCache(path.join(resolveClaudePaths().configDir, "plugins", "installed_plugins.json")); invalidateFsCache(path.join(getPluginsDir(), "installed_plugins.json")); for (const p of extraPaths ?? []) invalidateFsCache(p); clearClaudePluginRootsCache(); diff --git a/packages/coding-agent/src/session/claude-session-store.ts b/packages/coding-agent/src/session/claude-session-store.ts index 4861981b3..abc0122cf 100644 --- a/packages/coding-agent/src/session/claude-session-store.ts +++ b/packages/coding-agent/src/session/claude-session-store.ts @@ -1,6 +1,5 @@ import type * as fsTypes from "node:fs"; import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; import type { AssistantMessage, @@ -13,6 +12,7 @@ import type { UserMessage, } from "@oh-my-pi/pi-ai"; import { isRecord } from "@oh-my-pi/pi-utils"; +import { resolveClaudePaths } from "../config/claude-paths"; import { collectForeignJsonRecords, type ForeignJsonRecord, readForeignJsonRecords } from "./foreign-session-jsonl"; import type { ForeignSessionInfo, ForeignSessionStore } from "./foreign-session-store"; import type { ModelChangeEntry, SessionMessageEntry } from "./session-entries"; @@ -99,7 +99,8 @@ async function readHistoryIndex(file: string): Promise { - const config = path.join(path.dirname(root), ".claude.json"); + const { configDir, configFile } = resolveClaudePaths(); + const config = root === configDir ? configFile : path.join(path.dirname(root), ".claude.json"); try { const parsed: unknown = await Bun.file(config).json(); if (!isRecord(parsed) || !isRecord(parsed.projects)) return []; @@ -306,7 +307,7 @@ export class ClaudeSessionStore implements ForeignSessionStore { readonly #root: string; /** Creates a store rooted at Claude's data directory, or at a fixture root when supplied. */ - constructor(root: string = path.join(os.homedir(), ".claude")) { + constructor(root: string = resolveClaudePaths().configDir) { this.#root = path.resolve(root); } diff --git a/packages/coding-agent/test/discovery/claude-commands.test.ts b/packages/coding-agent/test/discovery/claude-commands.test.ts index 21896c659..461c7ac8d 100644 --- a/packages/coding-agent/test/discovery/claude-commands.test.ts +++ b/packages/coding-agent/test/discovery/claude-commands.test.ts @@ -18,11 +18,14 @@ describe("Claude Code slash command discovery", () => { let home = ""; let project = ""; let originalHome: string | undefined; + let originalClaudeConfigDir: string | undefined; beforeEach(async () => { clearFsCache(); resetSettingsForTest(); originalHome = process.env.HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-claude-commands-")); home = path.join(root, "home"); project = path.join(root, "project"); @@ -40,6 +43,11 @@ describe("Claude Code slash command discovery", () => { } else { process.env.HOME = originalHome; } + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } await removeWithRetries(root); }); @@ -61,6 +69,24 @@ describe("Claude Code slash command discovery", () => { expect(names).toContain("audit"); expect(names).toContain("team:audit"); }); + + test("loads user commands from CLAUDE_CONFIG_DIR instead of the legacy home", async () => { + const relocated = path.join(root, "relocated-claude"); + process.env.CLAUDE_CONFIG_DIR = relocated; + await writeFile(path.join(home, ".claude", "commands", "stale.md"), "Stale prompt\n"); + await writeFile(path.join(relocated, "commands", "active.md"), "Active prompt\n"); + + const result = await loadCapability(slashCommandCapability.id, { + cwd: project, + providers: ["claude"], + }); + + expect(result.warnings).toEqual([]); + expect(result.items.find(command => command.name === "active")?.path).toBe( + path.join(relocated, "commands", "active.md"), + ); + expect(result.items.some(command => command.name === "stale")).toBe(false); + }); test("keeps root commands ahead of nested basename duplicates", async () => { const rootApply = path.join(project, ".claude", "commands", "apply.md"); const nestedApply = path.join(project, ".claude", "commands", "agent", "apply.md"); diff --git a/packages/coding-agent/test/discovery/claude-plugins.test.ts b/packages/coding-agent/test/discovery/claude-plugins.test.ts index d38c62c0f..aa28719dd 100644 --- a/packages/coding-agent/test/discovery/claude-plugins.test.ts +++ b/packages/coding-agent/test/discovery/claude-plugins.test.ts @@ -76,6 +76,7 @@ describe("listClaudePluginRoots", () => { let originalAgentDirEnv: string | undefined; let originalOmpProfileEnv: string | undefined; let originalPiProfileEnv: string | undefined; + let originalClaudeConfigDir: string | undefined; beforeEach(async () => { clearClaudePluginRootsCache(); @@ -84,6 +85,8 @@ describe("listClaudePluginRoots", () => { originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR; originalOmpProfileEnv = process.env.OMP_PROFILE; originalPiProfileEnv = process.env.PI_PROFILE; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-plugins-test-")); testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-plugins-test-agent-")); process.env.HOME = tempDir; @@ -103,6 +106,7 @@ describe("listClaudePluginRoots", () => { restoreEnvValue("OMP_PROFILE", originalOmpProfileEnv); restoreEnvValue("PI_PROFILE", originalPiProfileEnv); restoreEnvValue("PI_CODING_AGENT_DIR", originalAgentDirEnv); + restoreEnvValue("CLAUDE_CONFIG_DIR", originalClaudeConfigDir); __resetDirsFromEnvForTests(); await removeWithRetries(tempDir); await removeWithRetries(testAgentDir); @@ -147,6 +151,41 @@ describe("listClaudePluginRoots", () => { }); }); + test("reads the user plugin registry from CLAUDE_CONFIG_DIR", async () => { + const relocated = path.join(tempDir, "relocated-claude"); + const pluginsDir = path.join(relocated, "plugins"); + process.env.CLAUDE_CONFIG_DIR = relocated; + await fs.mkdir(pluginsDir, { recursive: true }); + await fs.writeFile( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { + "relocated@market": [ + { + scope: "user", + installPath: "/path/to/relocated", + version: "1.0.0", + }, + ], + }, + }), + ); + + const result = await listClaudePluginRoots(tempDir); + + expect(result.roots).toEqual([ + { + id: "relocated@market", + marketplace: "market", + plugin: "relocated", + version: "1.0.0", + path: "/path/to/relocated", + scope: "user", + }, + ]); + }); + test("isolates local plugins to their canonical project", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const projectA = path.join(tempDir, "project-a"); @@ -355,6 +394,41 @@ describe("listClaudePluginRoots", () => { expect(result3.roots).toHaveLength(2); }); + test("isolates cached OMP plugin roots by home when Claude config is shared", async () => { + const sharedClaudeConfig = path.join(tempDir, "shared-claude"); + const firstHome = path.join(tempDir, "first-home"); + const secondHome = path.join(tempDir, "second-home"); + process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig; + for (const [home, pluginId] of [ + [firstHome, "first@market"], + [secondHome, "second@market"], + ] as const) { + const pluginsDir = path.join(home, ".omp", "plugins"); + await fs.mkdir(pluginsDir, { recursive: true }); + await fs.writeFile( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { + [pluginId]: [ + { + scope: "user", + installPath: `/path/to/${pluginId.split("@")[0]}`, + version: "1.0.0", + }, + ], + }, + }), + ); + } + + const first = await listClaudePluginRoots(firstHome); + const second = await listClaudePluginRoots(secondHome); + + expect(first.roots.map(root => root.id)).toEqual(["first@market"]); + expect(second.roots.map(root => root.id)).toEqual(["second@market"]); + }); + test("defaults scope to user when not specified", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); @@ -1305,15 +1379,19 @@ describe("listClaudePluginRoots", () => { describe("discoverAgents plugin precedence", () => { let tempDir: string; + let originalClaudeConfigDir: string | undefined; beforeEach(async () => { clearClaudePluginRootsCache(); clearFsCache(); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-plugins-precedence-test-")); }); afterEach(async () => { clearClaudePluginRootsCache(); + restoreEnvValue("CLAUDE_CONFIG_DIR", originalClaudeConfigDir); await removeWithRetries(tempDir); }); diff --git a/packages/coding-agent/test/discovery/pi-config-dir.test.ts b/packages/coding-agent/test/discovery/pi-config-dir.test.ts index 50b0bb171..3345d5932 100644 --- a/packages/coding-agent/test/discovery/pi-config-dir.test.ts +++ b/packages/coding-agent/test/discovery/pi-config-dir.test.ts @@ -3,6 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types"; import { getConfigDirs } from "@oh-my-pi/pi-coding-agent/config"; +import { resolveClaudePaths } from "@oh-my-pi/pi-coding-agent/config/claude-paths"; import { getUserPath } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; import { getAgentDir } from "@oh-my-pi/pi-utils"; @@ -37,3 +38,41 @@ describe("PI_CONFIG_DIR", () => { expect(result[0]).toEqual({ path: expected, source: ".omp", level: "user" }); }); }); + +describe("CLAUDE_CONFIG_DIR", () => { + const original = process.env.CLAUDE_CONFIG_DIR; + afterEach(() => { + if (original === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = original; + } + }); + + test("relocates Claude user discovery and .claude.json together", () => { + process.env.CLAUDE_CONFIG_DIR = "./fixtures/claude-home"; + const expectedRoot = path.resolve("./fixtures/claude-home"); + const ctx: LoadContext = { + cwd: "/work/project", + home: "/home/tester", + repoRoot: null, + }; + + expect(resolveClaudePaths(ctx.home)).toEqual({ + configDir: expectedRoot, + configFile: path.join(expectedRoot, ".claude.json"), + }); + expect(getUserPath(ctx, "claude", "commands")).toBe(path.join(expectedRoot, "commands")); + expect( + getConfigDirs("commands", { user: true, project: false }).find(entry => entry.source === ".claude"), + ).toEqual({ path: path.join(expectedRoot, "commands"), source: ".claude", level: "user" }); + }); + + test("keeps the legacy split paths when the override is unset", () => { + delete process.env.CLAUDE_CONFIG_DIR; + expect(resolveClaudePaths("/home/tester")).toEqual({ + configDir: path.join("/home/tester", ".claude"), + configFile: path.join("/home/tester", ".claude.json"), + }); + }); +}); diff --git a/packages/coding-agent/test/foreign-session-stores.test.ts b/packages/coding-agent/test/foreign-session-stores.test.ts index 324898be4..e3af3e875 100644 --- a/packages/coding-agent/test/foreign-session-stores.test.ts +++ b/packages/coding-agent/test/foreign-session-stores.test.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -12,12 +12,21 @@ import { SessionManager } from "../src/session/session-manager"; import { FileSessionStorage } from "../src/session/session-storage"; let tempRoot: string; +let originalClaudeConfigDir: string | undefined; beforeEach(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-foreign-sessions-")); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; }); afterEach(async () => { + vi.restoreAllMocks(); + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } await fs.rm(tempRoot, { recursive: true, force: true }); }); @@ -130,6 +139,30 @@ describe("ClaudeSessionStore", () => { expect(sessions[0]?.firstMessage).toBe("Legacy prompt"); expect(sessions[0]?.created.toISOString()).toBe("2025-01-01T00:00:00.000Z"); }); + + it("defaults to CLAUDE_CONFIG_DIR and reads its colocated project registry", async () => { + const root = path.join(tempRoot, "relocated-claude"); + const cwd = path.join(tempRoot, "project-with-hyphen"); + const id = "22222222-2222-4222-8222-333333333333"; + const encoded = cwd.replaceAll(path.sep, "-"); + process.env.CLAUDE_CONFIG_DIR = root; + await Bun.write(path.join(root, ".claude.json"), JSON.stringify({ projects: { [cwd]: {} } })); + await writeJsonl(path.join(root, "projects", encoded, `${id}.jsonl`), [ + { + type: "user", + uuid: "relocated-user", + parentUuid: null, + timestamp: "2025-01-01T00:00:00.000Z", + message: { content: "Relocated prompt" }, + }, + ]); + + const sessions = await new ClaudeSessionStore().list(); + + expect(sessions).toHaveLength(1); + expect(sessions[0]?.cwd).toBe(cwd); + expect(sessions[0]?.path).toBe(path.join(root, "projects", encoded, `${id}.jsonl`)); + }); }); describe("CodexSessionStore", () => {