/** * 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 { settings } from "../config/settings"; 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> { 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 }>(content); if (!json?.mcpServers) return []; const mcpServers = expandEnvVarsDeep(json.mcpServers); return Object.entries(mcpServers).map(([name, config]) => { const serverConfig = config as Record; return { name, timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined, command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, env: serverConfig.env as Record | undefined, url: serverConfig.url as string | undefined, headers: serverConfig.headers as Record | 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> { 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> { const userSkillsDir = path.join(getUserClaude(ctx), "skills"); // Walk up from cwd finding .claude/skills/ in ancestors const projectScans: Promise>[] = []; 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> { 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 // ============================================================================= /** * Read the Claude command-loading toggles from settings. * Falls back to true (current behavior) when settings are not initialized, * e.g. inside discovery unit tests that run without Settings.init(). */ function readClaudeCommandToggles(): { enableUser: boolean; enableProject: boolean } { try { return { enableUser: settings.get("commands.enableClaudeUser") ?? true, enableProject: settings.get("commands.enableClaudeProject") ?? true, }; } catch { return { enableUser: true, enableProject: true }; } } async function loadSlashCommands(ctx: LoadContext): Promise> { const items: SlashCommand[] = []; const warnings: string[] = []; const { enableUser, enableProject } = readClaudeCommandToggles(); if (enableUser) { const userBase = getUserClaude(ctx); const userCommandsDir = path.join(userBase, "commands"); const userResult = await loadFilesFromDir(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); } if (enableProject) { const projectCommandsDir = path.join(ctx.cwd, CONFIG_DIR, "commands"); const projectResult = await loadFilesFromDir(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> { 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(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> { const items: CustomTool[] = []; const warnings: string[] = []; const userBase = getUserClaude(ctx); const userToolsDir = path.join(userBase, "tools"); const userResult = await loadFilesFromDir(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(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> { 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> { 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>(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>(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(mcpCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load MCP servers from .claude.json and .claude/mcp.json", priority: PRIORITY, load: loadMCPServers, }); registerProvider(contextFileCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load CLAUDE.md files from .claude/ directories", priority: PRIORITY, load: loadContextFiles, }); registerProvider(skillCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load skills from .claude/skills/*/SKILL.md", priority: PRIORITY, load: loadSkills, }); registerProvider(extensionModuleCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load extension modules from .claude/extensions", priority: PRIORITY, load: loadExtensionModules, }); registerProvider(slashCommandCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load slash commands from .claude/commands/*.md", priority: PRIORITY, load: loadSlashCommands, }); registerProvider(hookCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load hooks from .claude/hooks/pre/ and .claude/hooks/post/", priority: PRIORITY, load: loadHooks, }); registerProvider(toolCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load custom tools from .claude/tools/", priority: PRIORITY, load: loadTools, }); registerProvider(settingsCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load settings from .claude/settings.json", priority: PRIORITY, load: loadSettings, }); registerProvider(systemPromptCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load system prompt from .claude/SYSTEM.md", priority: PRIORITY, load: loadSystemPrompts, });