Files
oh-my-pi/packages/coding-agent/src/discovery/claude.ts
T
16d7f9aec0 feat: monorepo-friendly discovery for AGENTS.md and skills (#293)
* feat: monorepo-friendly discovery for AGENTS.md and skills

Support hierarchical config discovery in monorepos by walking up from
cwd through ancestor directories, combining files from all levels
instead of only checking the immediate working directory.

AGENTS.md context files:
- Change dedup key from file.level to file.level + depth, so context
  files at different directory levels coexist instead of shadowing
- Root AGENTS.md and sub-project AGENTS.md are both included in the
  system prompt, ordered from least specific to most specific

Skills:
- Modify all 4 providers with project-level skill directories (native,
  claude, codex, opencode) to walk up from cwd through ancestors,
  scanning for skills at each level
- Skills at closer directories win on name conflicts via existing
  name-based dedup

Repo root boundary:
- Add findRepoRoot() utility that detects .git to identify repo root
- Add repoRoot field to LoadContext, computed once in loadCapability()
- All walk-up traversals (AGENTS.md, skills, nearest config dir) stop
  at the repo root, preventing discovery from leaking outside the repo
- When not in a git repo, falls back to walking to filesystem root

Files changed:
- capability/fs.ts: add findRepoRoot()
- capability/types.ts: add repoRoot to LoadContext
- capability/index.ts: compute repoRoot in loadCapability()
- capability/context-file.ts: depth-aware dedup key
- discovery/agents-md.ts: bound walk-up at repo root
- discovery/builtin.ts: getAncestorDirs stopAt param, skill walk-up
- discovery/claude.ts: skill walk-up with repo root bound
- discovery/codex.ts: skill walk-up with repo root bound
- discovery/opencode.ts: skill walk-up with repo root bound
- extensibility/skills.ts: add repoRoot to inline LoadContext

* feat(agents-provider): add project-level discovery with ancestor walk-up for all capability types

The agents provider (.agent/.agents directories) previously only loaded
capabilities from the user home directory (~/.agent/, ~/.agents/). This
meant project-level .agents/ directories in monorepo roots were not
discovered when sessions started from subdirectories.

Add getProjectPathCandidates() helper that walks from cwd up to repoRoot,
scanning both .agent/ and .agents/ at each ancestor level. Apply this to
all six capability types: skills, rules, prompts, commands, context files
(AGENTS.md), and system prompts (SYSTEM.md). This matches the ancestor
walk-up behavior already present in the builtin (.omp), claude, codex,
and opencode providers.

All loaders now parallelize project-level and user-level scans via
Promise.all. Project-level results appear closest-first so dedup at the
capability layer picks the nearest override.

* fix(discovery): remove hard cap of 20 on ancestor directory walking

All walk-up loops already terminate naturally at repoRoot or filesystem
root. The depth < 20 / .slice(0, 20) / MAX_DEPTH caps were redundant
safety guards that would silently stop discovery in deeply nested
projects.

Removed from: agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts,
opencode.ts, and corresponding test files.

* fix(agents-provider): set depth on project-level ContextFile entries for correct dedup

loadContextFiles was creating project-level ContextFile entries without
a depth field. Since contextFileCapability.key uses
`project:${file.depth ?? 0}`, all ancestor-level AGENTS.md files
collapsed to the same key and only the first survived dedup.

Compute depth via calculateDepth(cwd, ancestorDir) where ancestorDir is
two levels up from the file path (past the .agent/.agents config dir).
This gives each ancestor level a distinct dedup key.

* fix(discovery): stop ancestor walk-up at $HOME when not in a git repo

When repoRoot is null (no .git found), walk-up loops traversed all the
way to filesystem root. This caused $HOME to be scanned as a project-
level directory and then again as user-level, producing duplicates.

All providers now use `ctx.repoRoot ?? ctx.home` as the stop boundary:
stop at repoRoot if in a repo, otherwise stop at home. Applied to
agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts, and
opencode.ts.

* fix(context-files): clamp depth >= 0 in dedup key and fix provider depth computations

The dedup key `project:${file.depth}` used raw depth, which could be
negative when providers computed it from config subdirectories (e.g.
.claude/, .github/, .gemini/) rather than the ancestor directory. This
caused same-scope cwd-level files to get distinct keys like project:-1
and project:0, bypassing dedup and injecting conflicting instructions.

Two-layer fix:
1. Key function: clamp to Math.max(0, depth) so any file at or below
   cwd is treated as cwd-scope (depth 0). Defensive against future
   providers.
2. Providers: fix root cause in claude.ts, gemini.ts, github.ts to
   compute depth from the ancestor directory (parent of the config
   subdir), not the config subdir itself.

---------

Co-authored-by: Can Bölük <can1357@users.noreply.github.com>
2026-03-04 22:36:55 +01:00

543 lines
16 KiB
TypeScript

/**
* Claude Code Provider
*
* Loads configuration from .claude directories.
* Priority: 80 (tool-specific, below builtin but above shared standards)
*/
import * as path from "node:path";
import { hasFsCode, tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
import { readFile } from "../capability/fs";
import { type Hook, hookCapability } from "../capability/hook";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import { type Settings, settingsCapability } from "../capability/settings";
import { type Skill, skillCapability } from "../capability/skill";
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
import { type CustomTool, toolCapability } from "../capability/tool";
import type { LoadContext, LoadResult } from "../capability/types";
import {
calculateDepth,
createSourceMeta,
discoverExtensionModulePaths,
expandEnvVarsDeep,
getExtensionNameFromPath,
loadFilesFromDir,
scanSkillsFromDir,
} from "./helpers";
const PROVIDER_ID = "claude";
const DISPLAY_NAME = "Claude Code";
const PRIORITY = 80;
const CONFIG_DIR = ".claude";
/**
* Get user-level .claude path.
*/
function getUserClaude(ctx: LoadContext): string {
return path.join(ctx.home, CONFIG_DIR);
}
/**
* Get project-level .claude path (cwd only).
*/
function getProjectClaude(ctx: LoadContext): string {
return path.join(ctx.cwd, CONFIG_DIR);
}
function isMissingDirectoryError(error: unknown): boolean {
return hasFsCode(error, "ENOENT") || hasFsCode(error, "ENOTDIR");
}
// =============================================================================
// MCP Servers
// =============================================================================
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 userMcpJson = path.join(userBase, "mcp.json");
const projectBase = path.join(ctx.cwd, CONFIG_DIR);
const projectMcpJson = path.join(projectBase, ".mcp.json");
const projectMcpJsonAlt = path.join(projectBase, "mcp.json");
const userPaths = [
{ path: userClaudeJson, level: "user" as const },
{ path: userMcpJson, level: "user" as const },
];
const projectPaths = [
{ path: projectMcpJson, level: "project" as const },
{ path: projectMcpJsonAlt, level: "project" as const },
];
const allPaths = [...userPaths, ...projectPaths];
const contents = await Promise.all(allPaths.map(({ path }) => readFile(path)));
const parseMcpServers = (content: string | null, path: string, level: "user" | "project"): MCPServer[] => {
if (!content) return [];
const json = tryParseJson<{ mcpServers?: Record<string, unknown> }>(content);
if (!json?.mcpServers) return [];
const mcpServers = expandEnvVarsDeep(json.mcpServers);
return Object.entries(mcpServers).map(([name, config]) => {
const serverConfig = config as Record<string, unknown>;
return {
name,
command: serverConfig.command as string | undefined,
args: serverConfig.args as string[] | undefined,
env: serverConfig.env as Record<string, string> | undefined,
url: serverConfig.url as string | undefined,
headers: serverConfig.headers as Record<string, string> | undefined,
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, path, level),
};
});
};
for (let i = 0; i < userPaths.length; i++) {
const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level);
if (servers.length > 0) {
items.push(...servers);
break;
}
}
const projectOffset = userPaths.length;
for (let i = 0; i < projectPaths.length; i++) {
const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level);
if (servers.length > 0) {
items.push(...servers);
break;
}
}
return { items, warnings };
}
// =============================================================================
// Context Files (CLAUDE.md)
// =============================================================================
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userClaudeMd = path.join(userBase, "CLAUDE.md");
const userContent = await readFile(userClaudeMd);
if (userContent !== null) {
items.push({
path: userClaudeMd,
content: userContent,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userClaudeMd, "user"),
});
}
const projectBase = getProjectClaude(ctx);
const projectClaudeMd = path.join(projectBase, "CLAUDE.md");
const projectContent = await readFile(projectClaudeMd);
if (projectContent !== null) {
const depth = calculateDepth(ctx.cwd, path.dirname(projectBase), path.sep);
items.push({
path: projectClaudeMd,
content: projectContent,
level: "project",
depth,
_source: createSourceMeta(PROVIDER_ID, projectClaudeMd, "project"),
});
}
return { items, warnings };
}
// =============================================================================
// Skills
// =============================================================================
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const userSkillsDir = path.join(getUserClaude(ctx), "skills");
// Walk up from cwd finding .claude/skills/ in ancestors
const projectScans: Promise<LoadResult<Skill>>[] = [];
let current = ctx.cwd;
while (true) {
projectScans.push(
scanSkillsFromDir(ctx, {
dir: path.join(current, CONFIG_DIR, "skills"),
providerId: PROVIDER_ID,
level: "project",
}),
);
if (current === (ctx.repoRoot ?? ctx.home)) break;
const parent = path.dirname(current);
if (parent === current) break; // filesystem root
current = parent;
}
const [userResult, ...projectResults] = await Promise.allSettled([
scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
...projectScans,
]);
const items: Skill[] = [];
const warnings: string[] = [];
if (userResult.status === "fulfilled") {
items.push(...userResult.value.items);
warnings.push(...(userResult.value.warnings ?? []));
} else if (!isMissingDirectoryError(userResult.reason)) {
warnings.push(`Failed to scan Claude user skills in ${userSkillsDir}: ${String(userResult.reason)}`);
}
for (const projectResult of projectResults) {
if (projectResult.status === "fulfilled") {
items.push(...projectResult.value.items);
warnings.push(...(projectResult.value.warnings ?? []));
} else if (!isMissingDirectoryError(projectResult.reason)) {
warnings.push(`Failed to scan Claude project skills: ${String(projectResult.reason)}`);
}
}
return { items, warnings };
}
// =============================================================================
// Extension Modules
// =============================================================================
async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<ExtensionModule>> {
const items: ExtensionModule[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userExtensionsDir = path.join(userBase, "extensions");
const projectExtensionsDir = path.join(ctx.cwd, CONFIG_DIR, "extensions");
const dirsToDiscover: { dir: string; level: "user" | "project" }[] = [
{ dir: userExtensionsDir, level: "user" },
{ dir: projectExtensionsDir, level: "project" },
];
const pathsByLevel = await Promise.all(
dirsToDiscover.map(async ({ dir, level }) => {
const paths = await discoverExtensionModulePaths(ctx, dir);
return paths.map(extPath => ({ extPath, level }));
}),
);
for (const extensions of pathsByLevel) {
for (const { extPath, level } of extensions) {
items.push({
name: getExtensionNameFromPath(extPath),
path: extPath,
level,
_source: createSourceMeta(PROVIDER_ID, extPath, level),
});
}
}
return { items, warnings };
}
// =============================================================================
// Slash Commands
// =============================================================================
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
const items: SlashCommand[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userCommandsDir = path.join(userBase, "commands");
const userResult = await loadFilesFromDir<SlashCommand>(ctx, userCommandsDir, PROVIDER_ID, "user", {
extensions: ["md"],
transform: (name, content, path, source) => {
const cmdName = name.replace(/\.md$/, "");
return {
name: cmdName,
path,
content,
level: "user",
_source: source,
};
},
});
items.push(...userResult.items);
if (userResult.warnings) warnings.push(...userResult.warnings);
const projectCommandsDir = path.join(ctx.cwd, CONFIG_DIR, "commands");
const projectResult = await loadFilesFromDir<SlashCommand>(ctx, projectCommandsDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: (name, content, path, source) => {
const cmdName = name.replace(/\.md$/, "");
return {
name: cmdName,
path,
content,
level: "project",
_source: source,
};
},
});
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
return { items, warnings };
}
// =============================================================================
// Hooks
// =============================================================================
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
const items: Hook[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userHooksDir = path.join(userBase, "hooks");
const projectBase = getProjectClaude(ctx);
const projectHooksDir = path.join(projectBase, "hooks");
const hookTypes = ["pre", "post"] as const;
const loadTasks: { dir: string; hookType: "pre" | "post"; level: "user" | "project" }[] = [];
for (const hookType of hookTypes) {
loadTasks.push({ dir: path.join(userHooksDir, hookType), hookType, level: "user" });
}
for (const hookType of hookTypes) {
loadTasks.push({ dir: path.join(projectHooksDir, hookType), hookType, level: "project" });
}
const results = await Promise.all(
loadTasks.map(({ dir, hookType, level }) =>
loadFilesFromDir<Hook>(ctx, dir, PROVIDER_ID, level, {
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(sh|bash|zsh|fish)$/, "");
return {
name,
path,
type: hookType,
tool: toolName,
level,
_source: source,
};
},
}),
),
);
for (const result of results) {
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
// =============================================================================
// Custom Tools
// =============================================================================
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
const items: CustomTool[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userToolsDir = path.join(userBase, "tools");
const userResult = await loadFilesFromDir<CustomTool>(ctx, userToolsDir, PROVIDER_ID, "user", {
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
return {
name: toolName,
path,
description: `${toolName} custom tool`,
level: "user",
_source: source,
};
},
});
items.push(...userResult.items);
if (userResult.warnings) warnings.push(...userResult.warnings);
const projectBase = getProjectClaude(ctx);
const projectToolsDir = path.join(projectBase, "tools");
const projectResult = await loadFilesFromDir<CustomTool>(ctx, projectToolsDir, PROVIDER_ID, "project", {
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
return {
name: toolName,
path,
description: `${toolName} custom tool`,
level: "project",
_source: source,
};
},
});
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
return { items, warnings };
}
// =============================================================================
// System Prompts
// =============================================================================
async function loadSystemPrompts(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
const items: SystemPrompt[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userSystemMd = path.join(userBase, "SYSTEM.md");
const content = await readFile(userSystemMd);
if (content !== null) {
items.push({
path: userSystemMd,
content,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userSystemMd, "user"),
});
}
return { items, warnings };
}
// =============================================================================
// Settings
// =============================================================================
async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const items: Settings[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userSettingsJson = path.join(userBase, "settings.json");
const userContent = await readFile(userSettingsJson);
if (userContent) {
const data = tryParseJson<Record<string, unknown>>(userContent);
if (data) {
items.push({
path: userSettingsJson,
data,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userSettingsJson, "user"),
});
} else {
warnings.push(`Failed to parse JSON in ${userSettingsJson}`);
}
}
const projectBase = getProjectClaude(ctx);
const projectSettingsJson = path.join(projectBase, "settings.json");
const projectContent = await readFile(projectSettingsJson);
if (projectContent) {
const data = tryParseJson<Record<string, unknown>>(projectContent);
if (data) {
items.push({
path: projectSettingsJson,
data,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectSettingsJson, "project"),
});
} else {
warnings.push(`Failed to parse JSON in ${projectSettingsJson}`);
}
}
return { items, warnings };
}
// =============================================================================
// Provider Registration
// =============================================================================
registerProvider<MCPServer>(mcpCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load MCP servers from .claude.json and .claude/mcp.json",
priority: PRIORITY,
load: loadMCPServers,
});
registerProvider<ContextFile>(contextFileCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load CLAUDE.md files from .claude/ directories",
priority: PRIORITY,
load: loadContextFiles,
});
registerProvider<Skill>(skillCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load skills from .claude/skills/*/SKILL.md",
priority: PRIORITY,
load: loadSkills,
});
registerProvider<ExtensionModule>(extensionModuleCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load extension modules from .claude/extensions",
priority: PRIORITY,
load: loadExtensionModules,
});
registerProvider<SlashCommand>(slashCommandCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load slash commands from .claude/commands/*.md",
priority: PRIORITY,
load: loadSlashCommands,
});
registerProvider<Hook>(hookCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load hooks from .claude/hooks/pre/ and .claude/hooks/post/",
priority: PRIORITY,
load: loadHooks,
});
registerProvider<CustomTool>(toolCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load custom tools from .claude/tools/",
priority: PRIORITY,
load: loadTools,
});
registerProvider<Settings>(settingsCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load settings from .claude/settings.json",
priority: PRIORITY,
load: loadSettings,
});
registerProvider<SystemPrompt>(systemPromptCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load system prompt from .claude/SYSTEM.md",
priority: PRIORITY,
load: loadSystemPrompts,
});