Merge PR #8440: fix(discovery): honor Claude config directory (@roboomp)

This commit is contained in:
can1357
2026-08-13 18:01:31 +02:00
10 changed files with 224 additions and 24 deletions
+4
View File
@@ -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
+4 -3
View File
@@ -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,
}));
@@ -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") };
}
@@ -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<LoadResult<MCPServer>>
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);
+12 -11
View File
@@ -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<string, string | null>();
// ── 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();
@@ -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<Map<string, ClaudeHistory
}
async function readRegisteredProjects(root: string): Promise<string[]> {
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);
}
@@ -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<SlashCommand>(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");
@@ -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);
});
@@ -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"),
});
});
});
@@ -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", () => {