diff --git a/src/resources/extensions/universal-config/discovery.ts b/src/resources/extensions/universal-config/discovery.ts new file mode 100644 index 000000000..12768111b --- /dev/null +++ b/src/resources/extensions/universal-config/discovery.ts @@ -0,0 +1,78 @@ +/** + * Universal Config Discovery — main discovery orchestrator + * + * Runs all tool scanners in parallel and aggregates results into a + * unified DiscoveryResult. + */ + +import { homedir } from "node:os"; +import { TOOLS } from "./tools.js"; +import { SCANNERS } from "./scanners.js"; +import type { DiscoveryResult, DiscoveredItem, ToolDiscoveryResult } from "./types.js"; + +/** + * Run universal config discovery across all supported AI coding tools. + * + * @param projectRoot - Absolute path to the project root (cwd) + * @param home - Home directory override (defaults to os.homedir()) + * @returns Aggregated discovery result + */ +export async function discoverAllConfigs( + projectRoot: string, + home: string = homedir(), +): Promise { + const start = Date.now(); + const allWarnings: string[] = []; + const toolResults: ToolDiscoveryResult[] = []; + + // Run all scanners in parallel + const results = await Promise.allSettled( + TOOLS.map(async (tool) => { + const scanner = SCANNERS[tool.id]; + if (!scanner) return { tool, items: [] as DiscoveredItem[], warnings: [`No scanner for ${tool.id}`] }; + try { + const { items, warnings } = await scanner(projectRoot, home, tool); + return { tool, items, warnings }; + } catch (err) { + return { + tool, + items: [] as DiscoveredItem[], + warnings: [`Scanner error for ${tool.name}: ${err instanceof Error ? err.message : String(err)}`], + }; + } + }), + ); + + for (const result of results) { + if (result.status === "fulfilled") { + toolResults.push(result.value); + allWarnings.push(...result.value.warnings); + } else { + allWarnings.push(`Scanner failed: ${result.reason}`); + } + } + + const allItems = toolResults.flatMap((r) => r.items); + + const mcpServers = allItems.filter((i) => i.type === "mcp-server").length; + const rules = allItems.filter((i) => i.type === "rule").length; + const contextFiles = allItems.filter((i) => i.type === "context-file").length; + const settings = allItems.filter((i) => i.type === "settings").length; + const toolsWithConfig = toolResults.filter((r) => r.items.length > 0).length; + + return { + tools: toolResults, + allItems, + summary: { + mcpServers, + rules, + contextFiles, + settings, + totalItems: allItems.length, + toolsScanned: TOOLS.length, + toolsWithConfig, + }, + warnings: allWarnings, + durationMs: Date.now() - start, + }; +} diff --git a/src/resources/extensions/universal-config/format.ts b/src/resources/extensions/universal-config/format.ts new file mode 100644 index 000000000..be6b69a54 --- /dev/null +++ b/src/resources/extensions/universal-config/format.ts @@ -0,0 +1,160 @@ +/** + * Universal Config Discovery — output formatting + * + * Formats DiscoveryResult into human-readable and LLM-readable output. + */ + +import type { DiscoveryResult, DiscoveredItem, ToolDiscoveryResult } from "./types.js"; + +/** + * Format discovery result as a compact text report for the LLM tool response. + */ +export function formatDiscoveryForTool(result: DiscoveryResult): string { + const lines: string[] = []; + const { summary } = result; + + lines.push(`Universal Config Discovery — ${summary.toolsWithConfig}/${summary.toolsScanned} tools with config (${result.durationMs}ms)`); + lines.push(""); + + if (summary.totalItems === 0) { + lines.push("No configuration found from any AI coding tool."); + lines.push(""); + lines.push("Scanned for: Claude Code, Cursor, Windsurf, Gemini CLI, Codex, Cline, GitHub Copilot, VS Code"); + return lines.join("\n"); + } + + lines.push(`Found: ${summary.mcpServers} MCP server(s), ${summary.rules} rule(s), ${summary.contextFiles} context file(s), ${summary.settings} settings file(s)`); + lines.push(""); + + for (const toolResult of result.tools) { + if (toolResult.items.length === 0) continue; + lines.push(`## ${toolResult.tool.name}`); + + const byType = groupByType(toolResult.items); + + if (byType["mcp-server"]?.length) { + lines.push(` MCP Servers (${byType["mcp-server"].length}):`); + for (const item of byType["mcp-server"]) { + if (item.type !== "mcp-server") continue; + const transport = item.transport ?? (item.url ? "http" : item.command ? "stdio" : "unknown"); + const detail = item.command + ? `${item.command}${item.args?.length ? ` ${item.args.join(" ")}` : ""}` + : item.url ?? "no endpoint"; + lines.push(` - ${item.name} [${transport}] ${detail} (${item.source.level})`); + } + } + + if (byType.rule?.length) { + lines.push(` Rules (${byType.rule.length}):`); + for (const item of byType.rule) { + if (item.type !== "rule") continue; + const meta: string[] = []; + if (item.alwaysApply) meta.push("always"); + if (item.globs?.length) meta.push(`globs: ${item.globs.join(", ")}`); + const suffix = meta.length ? ` [${meta.join(", ")}]` : ""; + const preview = item.content.slice(0, 80).replace(/\n/g, " ").trim(); + lines.push(` - ${item.name}${suffix}: ${preview}${item.content.length > 80 ? "..." : ""}`); + } + } + + if (byType["context-file"]?.length) { + lines.push(` Context Files (${byType["context-file"].length}):`); + for (const item of byType["context-file"]) { + if (item.type !== "context-file") continue; + const size = item.content.length; + lines.push(` - ${item.name} (${size} chars, ${item.source.level}) ${item.source.path}`); + } + } + + if (byType.settings?.length) { + lines.push(` Settings (${byType.settings.length}):`); + for (const item of byType.settings) { + if (item.type !== "settings") continue; + const keys = Object.keys(item.data).slice(0, 5); + const suffix = Object.keys(item.data).length > 5 ? ` +${Object.keys(item.data).length - 5} more` : ""; + lines.push(` - ${item.source.path} (${item.source.level}): keys: ${keys.join(", ")}${suffix}`); + } + } + + lines.push(""); + } + + if (result.warnings.length > 0) { + lines.push("Warnings:"); + for (const w of result.warnings) { + lines.push(` - ${w}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +/** + * Format discovery result as a structured summary for /configs command output. + */ +export function formatDiscoveryForCommand(result: DiscoveryResult): string[] { + const lines: string[] = []; + const { summary } = result; + + lines.push(`--- Universal Config Discovery ---`); + lines.push(`${summary.toolsWithConfig} of ${summary.toolsScanned} tools have configuration`); + lines.push(`${summary.totalItems} total items discovered in ${result.durationMs}ms`); + lines.push(""); + + if (summary.totalItems === 0) { + lines.push("No configuration found."); + return lines; + } + + lines.push(` MCP Servers: ${summary.mcpServers}`); + lines.push(` Rules: ${summary.rules}`); + lines.push(` Context: ${summary.contextFiles}`); + lines.push(` Settings: ${summary.settings}`); + lines.push(""); + + for (const toolResult of result.tools) { + if (toolResult.items.length === 0) continue; + + const counts = countByType(toolResult.items); + const parts: string[] = []; + if (counts["mcp-server"]) parts.push(`${counts["mcp-server"]} MCP`); + if (counts.rule) parts.push(`${counts.rule} rules`); + if (counts["context-file"]) parts.push(`${counts["context-file"]} context`); + if (counts.settings) parts.push(`${counts.settings} settings`); + + lines.push(` ${toolResult.tool.name}: ${parts.join(", ")}`); + + // Show MCP server names + const servers = toolResult.items.filter((i) => i.type === "mcp-server"); + for (const server of servers) { + if (server.type !== "mcp-server") continue; + lines.push(` MCP: ${server.name} (${server.source.level})`); + } + } + + if (result.warnings.length > 0) { + lines.push(""); + lines.push(`${result.warnings.length} warning(s) — run discover_configs tool for details`); + } + + return lines; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function groupByType(items: DiscoveredItem[]): Record { + const groups: Record = {}; + for (const item of items) { + (groups[item.type] ??= []).push(item); + } + return groups; +} + +function countByType(items: DiscoveredItem[]): Record { + const counts: Record = {}; + for (const item of items) { + counts[item.type] = (counts[item.type] ?? 0) + 1; + } + return counts; +} diff --git a/src/resources/extensions/universal-config/index.ts b/src/resources/extensions/universal-config/index.ts new file mode 100644 index 000000000..9201c5e88 --- /dev/null +++ b/src/resources/extensions/universal-config/index.ts @@ -0,0 +1,118 @@ +/** + * Universal Config Discovery Extension + * + * Auto-detects and displays configuration from 8 AI coding tools: + * Claude Code, Cursor, Windsurf, Gemini CLI, Codex, Cline, + * GitHub Copilot, and VS Code. + * + * Discovers: MCP servers, rules/instructions, context files, and settings. + * + * Read-only: never modifies other tools' config files. + * + * Provides: + * - discover_configs tool (LLM-callable) + * - /configs command (slash command) + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { discoverAllConfigs } from "./discovery.js"; +import { formatDiscoveryForTool, formatDiscoveryForCommand } from "./format.js"; +import type { DiscoveryResult, ToolId } from "./types.js"; + +// Cache discovery result within a session to avoid re-scanning +let cachedResult: DiscoveryResult | null = null; + +export default function universalConfig(pi: ExtensionAPI) { + // ── Tool: discover_configs ────────────────────────────────────────────── + + pi.registerTool({ + name: "discover_configs", + label: "Discover Configs", + description: + "Scan for existing AI coding tool configurations in this project and the user's home directory. " + + "Discovers MCP servers, rules, context files, and settings from Claude Code, Cursor, Windsurf, " + + "Gemini CLI, Codex, Cline, GitHub Copilot, and VS Code. Read-only — never modifies config files.", + promptSnippet: "Discover existing AI tool configs (MCP servers, rules, context files) from 8 coding tools.", + promptGuidelines: [ + "Use discover_configs when a user asks about their existing configuration, MCP servers, or when switching from another AI coding tool.", + "The tool scans both user-level (~/) and project-level (./) config directories.", + "Results include MCP servers that could be reused, rules/instructions that could be adapted, and context files from other tools.", + ], + parameters: Type.Object({ + tool: Type.Optional( + Type.String({ + description: + "Filter to a specific tool: claude, cursor, windsurf, gemini, codex, cline, github-copilot, vscode. Omit to scan all.", + }), + ), + refresh: Type.Optional( + Type.Boolean({ + description: "Force re-scan even if cached results exist. Default: false.", + }), + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (params.refresh || !cachedResult) { + cachedResult = await discoverAllConfigs(ctx.cwd); + } + + let result = cachedResult; + + // Filter to specific tool if requested + if (params.tool) { + const toolId = params.tool as ToolId; + const filtered = result.tools.filter((t) => t.tool.id === toolId); + if (filtered.length === 0) { + return { + content: [{ type: "text", text: `No scanner found for tool "${params.tool}". Valid tools: claude, cursor, windsurf, gemini, codex, cline, github-copilot, vscode` }], + isError: true, + details: undefined as unknown, + }; + } + // Rebuild result with filtered tools + const allItems = filtered.flatMap((t) => t.items); + result = { + ...result, + tools: filtered, + allItems, + summary: { + ...result.summary, + mcpServers: allItems.filter((i) => i.type === "mcp-server").length, + rules: allItems.filter((i) => i.type === "rule").length, + contextFiles: allItems.filter((i) => i.type === "context-file").length, + settings: allItems.filter((i) => i.type === "settings").length, + totalItems: allItems.length, + toolsWithConfig: filtered.filter((t) => t.items.length > 0).length, + }, + }; + } + + const text = formatDiscoveryForTool(result); + return { + content: [{ type: "text", text }], + details: undefined as unknown, + }; + }, + }); + + // ── Command: /configs ─────────────────────────────────────────────────── + + pi.registerCommand("configs", { + description: "Show discovered AI tool configurations (MCP servers, rules, context files)", + async handler(_args: string, ctx: ExtensionCommandContext) { + // Always refresh on command invocation + cachedResult = await discoverAllConfigs(ctx.cwd); + const lines = formatDiscoveryForCommand(cachedResult); + + ctx.ui.notify(lines.join("\n"), "info"); + }, + }); + + // ── Invalidate cache on session switch ────────────────────────────────── + + pi.on("session_switch", () => { + cachedResult = null; + }); +} diff --git a/src/resources/extensions/universal-config/package.json b/src/resources/extensions/universal-config/package.json new file mode 100644 index 000000000..b88eb676f --- /dev/null +++ b/src/resources/extensions/universal-config/package.json @@ -0,0 +1,11 @@ +{ + "name": "pi-extension-universal-config", + "private": true, + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/resources/extensions/universal-config/scanners.ts b/src/resources/extensions/universal-config/scanners.ts new file mode 100644 index 000000000..874a7b0f4 --- /dev/null +++ b/src/resources/extensions/universal-config/scanners.ts @@ -0,0 +1,579 @@ +/** + * Universal Config Discovery — per-tool scanners + * + * Each scanner reads config files for a specific AI coding tool and + * normalizes them to DiscoveredItem[]. Read-only: never modifies files. + * + * Config path sources verified against Oh My Pi's discovery module. + */ + +import { readFile, readdir, stat } from "node:fs/promises"; +import { join, basename, resolve } from "node:path"; +import { homedir } from "node:os"; +import type { + ConfigSource, + ConfigLevel, + DiscoveredItem, + DiscoveredMCPServer, + DiscoveredRule, + DiscoveredContextFile, + DiscoveredSettings, + ToolDiscoveryResult, + ToolId, + ToolInfo, +} from "./types.js"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function source(tool: ToolInfo, path: string, level: ConfigLevel): ConfigSource { + return { tool: tool.id, toolName: tool.name, path, level }; +} + +async function readTextFile(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return null; + } +} + +function tryParseJson(content: string): T | null { + try { + return JSON.parse(content) as T; + } catch { + return null; + } +} + +async function fileExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function isDirectory(path: string): Promise { + try { + const s = await stat(path); + return s.isDirectory(); + } catch { + return false; + } +} + +async function readDirSafe(dir: string): Promise { + try { + return await readdir(dir); + } catch { + return []; + } +} + +/** + * Parse MDC/YAML frontmatter from a markdown file. + * Returns the frontmatter as key-value pairs and the body content. + */ +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (!match) { + return { frontmatter: {}, body: content }; + } + + const rawFm = match[1] ?? ""; + const body = match[2] ?? ""; + const frontmatter: Record = {}; + + for (const line of rawFm.split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + let value: unknown = line.slice(colonIdx + 1).trim(); + + // Strip surrounding quotes from YAML string values + if (typeof value === "string" && /^["'].*["']$/.test(value)) { + value = value.slice(1, -1); + } + + // Parse simple types + if (value === "true") value = true; + else if (value === "false") value = false; + else if (typeof value === "string" && /^\d+$/.test(value)) value = parseInt(value, 10); + + frontmatter[key] = value; + } + + return { frontmatter, body }; +} + +/** + * Parse MCP servers from a JSON object with `mcpServers` key. + * Common format used by Claude Code, Cursor, Windsurf, Gemini CLI. + */ +function parseMcpServersFromJson( + json: Record, + filePath: string, + tool: ToolInfo, + level: ConfigLevel, +): DiscoveredMCPServer[] { + const servers: DiscoveredMCPServer[] = []; + const mcpServers = json.mcpServers as Record | undefined; + if (!mcpServers || typeof mcpServers !== "object") return servers; + + for (const [name, config] of Object.entries(mcpServers)) { + if (!config || typeof config !== "object") continue; + const c = config as Record; + servers.push({ + type: "mcp-server", + name, + command: typeof c.command === "string" ? c.command : undefined, + args: Array.isArray(c.args) ? (c.args as string[]) : undefined, + env: c.env && typeof c.env === "object" ? (c.env as Record) : undefined, + url: typeof c.url === "string" ? c.url : undefined, + transport: ["stdio", "sse", "http"].includes(c.type as string) + ? (c.type as "stdio" | "sse" | "http") + : undefined, + source: source(tool, filePath, level), + }); + } + return servers; +} + +// ── Per-tool scanners ───────────────────────────────────────────────────────── + +type Scanner = (projectRoot: string, home: string, tool: ToolInfo) => Promise<{ items: DiscoveredItem[]; warnings: string[] }>; + +// ---------- Claude Code ---------- + +async function scanClaude(projectRoot: string, home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + // User-level MCP: ~/.claude.json or ~/.claude/mcp.json + for (const relPath of [".claude.json", ".claude/mcp.json"]) { + const fullPath = join(home, relPath); + const content = await readTextFile(fullPath); + if (content) { + const json = tryParseJson>(content); + if (json) { + const servers = parseMcpServersFromJson(json, fullPath, tool, "user"); + if (servers.length > 0) { + items.push(...servers); + break; // First hit wins (matches Oh My Pi behavior) + } + } + } + } + + // Project-level MCP: .claude/.mcp.json or .claude/mcp.json + for (const relPath of [".claude/.mcp.json", ".claude/mcp.json"]) { + const fullPath = join(projectRoot, relPath); + const content = await readTextFile(fullPath); + if (content) { + const json = tryParseJson>(content); + if (json) { + const servers = parseMcpServersFromJson(json, fullPath, tool, "project"); + if (servers.length > 0) { + items.push(...servers); + break; + } + } + } + } + + // User-level context: ~/.claude/CLAUDE.md + const userClaudeMd = join(home, ".claude/CLAUDE.md"); + const userMdContent = await readTextFile(userClaudeMd); + if (userMdContent) { + items.push({ + type: "context-file", + name: "CLAUDE.md (user)", + content: userMdContent, + source: source(tool, userClaudeMd, "user"), + }); + } + + // Project-level context: CLAUDE.md (root) and .claude/CLAUDE.md + for (const relPath of ["CLAUDE.md", ".claude/CLAUDE.md"]) { + const fullPath = join(projectRoot, relPath); + const content = await readTextFile(fullPath); + if (content) { + items.push({ + type: "context-file", + name: `${relPath}`, + content, + source: source(tool, fullPath, "project"), + }); + } + } + + // User-level settings: ~/.claude/settings.json + const userSettings = join(home, ".claude/settings.json"); + const settingsContent = await readTextFile(userSettings); + if (settingsContent) { + const json = tryParseJson>(settingsContent); + if (json) { + items.push({ type: "settings", data: json, source: source(tool, userSettings, "user") }); + } + } + + return { items, warnings }; +} + +// ---------- Cursor ---------- + +async function scanCursor(projectRoot: string, home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + // MCP servers: ~/.cursor/mcp.json and .cursor/mcp.json + for (const { dir, level } of [ + { dir: home, level: "user" as ConfigLevel }, + { dir: projectRoot, level: "project" as ConfigLevel }, + ]) { + const mcpPath = join(dir, ".cursor/mcp.json"); + const content = await readTextFile(mcpPath); + if (content) { + const json = tryParseJson>(content); + if (json) items.push(...parseMcpServersFromJson(json, mcpPath, tool, level)); + } + } + + // Rules: .cursor/rules/*.mdc and .cursor/rules/*.md + const projectRulesDir = join(projectRoot, ".cursor/rules"); + const ruleFiles = await readDirSafe(projectRulesDir); + for (const file of ruleFiles) { + if (!file.endsWith(".mdc") && !file.endsWith(".md")) continue; + const filePath = join(projectRulesDir, file); + const content = await readTextFile(filePath); + if (!content) continue; + + const { frontmatter, body } = parseFrontmatter(content); + items.push({ + type: "rule", + name: file.replace(/\.(mdc|md)$/, ""), + content: body, + globs: typeof frontmatter.globs === "string" ? [frontmatter.globs] : undefined, + alwaysApply: frontmatter.alwaysApply === true, + description: typeof frontmatter.description === "string" ? frontmatter.description : undefined, + source: source(tool, filePath, "project"), + }); + } + + // Legacy: .cursorrules (root-level file) + const legacyRulesPath = join(projectRoot, ".cursorrules"); + const legacyContent = await readTextFile(legacyRulesPath); + if (legacyContent) { + items.push({ + type: "rule", + name: "cursorrules (legacy)", + content: legacyContent, + alwaysApply: true, + source: source(tool, legacyRulesPath, "project"), + }); + } + + // Settings: .cursor/settings.json + const settingsPath = join(projectRoot, ".cursor/settings.json"); + const settingsContent = await readTextFile(settingsPath); + if (settingsContent) { + const json = tryParseJson>(settingsContent); + if (json) items.push({ type: "settings", data: json, source: source(tool, settingsPath, "project") }); + } + + return { items, warnings }; +} + +// ---------- Windsurf ---------- + +async function scanWindsurf(projectRoot: string, home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + // MCP servers: ~/.codeium/windsurf/mcp_config.json and .windsurf/mcp_config.json + for (const { path: mcpPath, level } of [ + { path: join(home, ".codeium/windsurf/mcp_config.json"), level: "user" as ConfigLevel }, + { path: join(projectRoot, ".windsurf/mcp_config.json"), level: "project" as ConfigLevel }, + ]) { + const content = await readTextFile(mcpPath); + if (content) { + const json = tryParseJson>(content); + if (json) items.push(...parseMcpServersFromJson(json, mcpPath, tool, level)); + } + } + + // User rules: ~/.codeium/windsurf/memories/global_rules.md + const globalRulesPath = join(home, ".codeium/windsurf/memories/global_rules.md"); + const globalRules = await readTextFile(globalRulesPath); + if (globalRules) { + items.push({ + type: "rule", + name: "global_rules", + content: globalRules, + alwaysApply: true, + source: source(tool, globalRulesPath, "user"), + }); + } + + // Project rules: .windsurf/rules/*.md + const rulesDir = join(projectRoot, ".windsurf/rules"); + const ruleFiles = await readDirSafe(rulesDir); + for (const file of ruleFiles) { + if (!file.endsWith(".md")) continue; + const filePath = join(rulesDir, file); + const content = await readTextFile(filePath); + if (!content) continue; + const { frontmatter, body } = parseFrontmatter(content); + items.push({ + type: "rule", + name: file.replace(/\.md$/, ""), + content: body, + description: typeof frontmatter.description === "string" ? frontmatter.description : undefined, + source: source(tool, filePath, "project"), + }); + } + + // Legacy: .windsurfrules + const legacyPath = join(projectRoot, ".windsurfrules"); + const legacyContent = await readTextFile(legacyPath); + if (legacyContent) { + items.push({ + type: "rule", + name: "windsurfrules (legacy)", + content: legacyContent, + alwaysApply: true, + source: source(tool, legacyPath, "project"), + }); + } + + return { items, warnings }; +} + +// ---------- Gemini CLI ---------- + +async function scanGemini(projectRoot: string, home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + // MCP servers: ~/.gemini/settings.json and .gemini/settings.json + for (const { path: settingsPath, level } of [ + { path: join(home, ".gemini/settings.json"), level: "user" as ConfigLevel }, + { path: join(projectRoot, ".gemini/settings.json"), level: "project" as ConfigLevel }, + ]) { + const content = await readTextFile(settingsPath); + if (content) { + const json = tryParseJson>(content); + if (json) { + items.push(...parseMcpServersFromJson(json, settingsPath, tool, level)); + items.push({ type: "settings", data: json, source: source(tool, settingsPath, level) }); + } + } + } + + // Context files: ~/.gemini/GEMINI.md and .gemini/GEMINI.md + for (const { path: mdPath, level } of [ + { path: join(home, ".gemini/GEMINI.md"), level: "user" as ConfigLevel }, + { path: join(projectRoot, ".gemini/GEMINI.md"), level: "project" as ConfigLevel }, + ]) { + const content = await readTextFile(mdPath); + if (content) { + items.push({ + type: "context-file", + name: `GEMINI.md (${level})`, + content, + source: source(tool, mdPath, level), + }); + } + } + + return { items, warnings }; +} + +// ---------- Codex ---------- + +async function scanCodex(projectRoot: string, home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + // Context file: ~/.codex/AGENTS.md + const agentsMdPath = join(home, ".codex/AGENTS.md"); + const agentsMd = await readTextFile(agentsMdPath); + if (agentsMd) { + items.push({ + type: "context-file", + name: "AGENTS.md (user)", + content: agentsMd, + source: source(tool, agentsMdPath, "user"), + }); + } + + // Project-level: AGENTS.md at root (Codex convention) + const projectAgentsMd = join(projectRoot, "AGENTS.md"); + const projectContent = await readTextFile(projectAgentsMd); + if (projectContent) { + items.push({ + type: "context-file", + name: "AGENTS.md (project)", + content: projectContent, + source: source(tool, projectAgentsMd, "project"), + }); + } + + // Codex uses TOML for MCP config — we parse only the JSON subset + // (TOML parsing would require a dependency; skip for now, log warning) + for (const { path: tomlPath, level } of [ + { path: join(home, ".codex/config.toml"), level: "user" as ConfigLevel }, + { path: join(projectRoot, ".codex/config.toml"), level: "project" as ConfigLevel }, + ]) { + if (await fileExists(tomlPath)) { + warnings.push(`Found ${tomlPath} (TOML config) — MCP server parsing from TOML not yet supported`); + } + } + + return { items, warnings }; +} + +// ---------- Cline ---------- + +async function scanCline(projectRoot: string, _home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + const clinerulesPath = join(projectRoot, ".clinerules"); + + if (await isDirectory(clinerulesPath)) { + // Directory format: .clinerules/*.md + const files = await readDirSafe(clinerulesPath); + for (const file of files) { + if (!file.endsWith(".md")) continue; + const filePath = join(clinerulesPath, file); + const content = await readTextFile(filePath); + if (!content) continue; + const { body } = parseFrontmatter(content); + items.push({ + type: "rule", + name: file.replace(/\.md$/, ""), + content: body, + alwaysApply: true, + source: source(tool, filePath, "project"), + }); + } + } else { + // Single file format + const content = await readTextFile(clinerulesPath); + if (content) { + items.push({ + type: "rule", + name: "clinerules", + content, + alwaysApply: true, + source: source(tool, clinerulesPath, "project"), + }); + } + } + + // Cline MCP: .cline/mcp_settings.json (VS Code extension stores MCP here) + const clineMcpPath = join(projectRoot, ".cline/mcp_settings.json"); + const clineMcpContent = await readTextFile(clineMcpPath); + if (clineMcpContent) { + const json = tryParseJson>(clineMcpContent); + if (json) items.push(...parseMcpServersFromJson(json, clineMcpPath, tool, "project")); + } + + return { items, warnings }; +} + +// ---------- GitHub Copilot ---------- + +async function scanGithubCopilot(projectRoot: string, _home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + // Context file: .github/copilot-instructions.md + const instructionsPath = join(projectRoot, ".github/copilot-instructions.md"); + const instructions = await readTextFile(instructionsPath); + if (instructions) { + items.push({ + type: "context-file", + name: "copilot-instructions.md", + content: instructions, + source: source(tool, instructionsPath, "project"), + }); + } + + // Instructions: .github/instructions/*.instructions.md + const instructionsDir = join(projectRoot, ".github/instructions"); + const instrFiles = await readDirSafe(instructionsDir); + for (const file of instrFiles) { + if (!file.endsWith(".instructions.md")) continue; + const filePath = join(instructionsDir, file); + const content = await readTextFile(filePath); + if (!content) continue; + const { frontmatter, body } = parseFrontmatter(content); + const applyTo = typeof frontmatter.applyTo === "string" ? frontmatter.applyTo : undefined; + items.push({ + type: "rule", + name: file.replace(".instructions.md", ""), + content: body, + globs: applyTo ? [applyTo] : undefined, + description: `GitHub Copilot instruction${applyTo ? ` (applies to: ${applyTo})` : ""}`, + source: source(tool, filePath, "project"), + }); + } + + return { items, warnings }; +} + +// ---------- VS Code ---------- + +async function scanVSCode(projectRoot: string, _home: string, tool: ToolInfo): Promise<{ items: DiscoveredItem[]; warnings: string[] }> { + const items: DiscoveredItem[] = []; + const warnings: string[] = []; + + // Settings: .vscode/settings.json (may contain MCP servers and AI settings) + const settingsPath = join(projectRoot, ".vscode/settings.json"); + const settingsContent = await readTextFile(settingsPath); + if (settingsContent) { + const json = tryParseJson>(settingsContent); + if (json) { + items.push({ type: "settings", data: json, source: source(tool, settingsPath, "project") }); + + // VS Code MCP servers: look for mcp-related keys + // Format varies: "mcp.servers", "mcpServers", etc. + const mcpServers = (json["mcp.servers"] ?? json.mcpServers ?? (json.mcp as Record)?.servers) as Record | undefined; + if (mcpServers && typeof mcpServers === "object") { + items.push(...parseMcpServersFromJson({ mcpServers }, settingsPath, tool, "project")); + } + } + } + + // VS Code MCP config: .vscode/mcp.json + const mcpPath = join(projectRoot, ".vscode/mcp.json"); + const mcpContent = await readTextFile(mcpPath); + if (mcpContent) { + const json = tryParseJson>(mcpContent); + if (json) { + // VS Code uses { servers: { ... } } or { mcpServers: { ... } } + const servers = (json.servers ?? json.mcpServers) as Record | undefined; + if (servers && typeof servers === "object") { + items.push(...parseMcpServersFromJson({ mcpServers: servers }, mcpPath, tool, "project")); + } + } + } + + return { items, warnings }; +} + +// ── Scanner registry ────────────────────────────────────────────────────────── + +export const SCANNERS: Record = { + claude: scanClaude, + cursor: scanCursor, + windsurf: scanWindsurf, + gemini: scanGemini, + codex: scanCodex, + cline: scanCline, + "github-copilot": scanGithubCopilot, + vscode: scanVSCode, +}; diff --git a/src/resources/extensions/universal-config/tests/discovery.test.ts b/src/resources/extensions/universal-config/tests/discovery.test.ts new file mode 100644 index 000000000..7d3706881 --- /dev/null +++ b/src/resources/extensions/universal-config/tests/discovery.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the discovery orchestrator. + * Runs with: node --experimental-strip-types --test + */ + +import { describe, test } from "node:test"; +import { strict as assert } from "node:assert"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { discoverAllConfigs } from "../discovery.ts"; + +function mkdirp(path: string) { + mkdirSync(path, { recursive: true }); +} + +function writeJson(path: string, data: unknown) { + mkdirp(join(path, "..")); + writeFileSync(path, JSON.stringify(data, null, 2), "utf8"); +} + +function writeText(path: string, content: string) { + mkdirp(join(path, "..")); + writeFileSync(path, content, "utf8"); +} + +function makeTempDirs() { + const base = join(tmpdir(), `ucd-disc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + const testRoot = join(base, "project"); + const testHome = join(base, "home"); + mkdirp(testRoot); + mkdirp(testHome); + return { testRoot, testHome, cleanup: () => rmSync(base, { recursive: true, force: true }) }; +} + +describe("discoverAllConfigs", () => { + test("returns empty result for clean directories", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + const result = await discoverAllConfigs(testRoot, testHome); + assert.equal(result.summary.totalItems, 0); + assert.equal(result.summary.toolsScanned, 8); + assert.equal(result.summary.toolsWithConfig, 0); + assert.ok(result.durationMs >= 0); + } finally { + cleanup(); + } + }); + + test("discovers config from multiple tools", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testHome, ".claude.json"), { + mcpServers: { "claude-mcp": { command: "node", args: ["server.js"] } }, + }); + writeText(join(testRoot, ".cursorrules"), "Use semicolons."); + writeText(join(testRoot, ".github/copilot-instructions.md"), "Be helpful."); + + const result = await discoverAllConfigs(testRoot, testHome); + assert.equal(result.summary.toolsWithConfig, 3); + assert.equal(result.summary.mcpServers, 1); + assert.equal(result.summary.rules, 1); + assert.equal(result.summary.contextFiles, 1); + assert.equal(result.allItems.length, 3); + } finally { + cleanup(); + } + }); + + test("handles nonexistent paths gracefully", async () => { + const result = await discoverAllConfigs("/nonexistent/path", "/nonexistent/home"); + assert.equal(result.summary.totalItems, 0); + assert.ok(result.warnings.length >= 0); + }); + + test("groups items by tool", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".cursor/mcp.json"), { + mcpServers: { s1: { command: "a" }, s2: { command: "b" } }, + }); + + const result = await discoverAllConfigs(testRoot, testHome); + const cursorResult = result.tools.find((t) => t.tool.id === "cursor"); + assert.ok(cursorResult); + assert.equal(cursorResult!.items.length, 2); + } finally { + cleanup(); + } + }); + + test("summary counts are accurate", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".cursor/mcp.json"), { mcpServers: { s1: { command: "a" } } }); + writeText(join(testRoot, ".cursorrules"), "Rule 1"); + writeText(join(testRoot, ".clinerules"), "Rule 2"); + writeText(join(testRoot, ".github/copilot-instructions.md"), "Instructions"); + writeJson(join(testRoot, ".vscode/settings.json"), { "editor.tabSize": 2 }); + + const result = await discoverAllConfigs(testRoot, testHome); + assert.equal(result.summary.mcpServers, 1); + assert.equal(result.summary.rules, 2); + assert.equal(result.summary.contextFiles, 1); + assert.equal(result.summary.settings, 1); + assert.equal(result.summary.totalItems, 5); + } finally { + cleanup(); + } + }); +}); diff --git a/src/resources/extensions/universal-config/tests/format.test.ts b/src/resources/extensions/universal-config/tests/format.test.ts new file mode 100644 index 000000000..0d0f48370 --- /dev/null +++ b/src/resources/extensions/universal-config/tests/format.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for output formatting. + * Runs with: node --experimental-strip-types --test + */ + +import { describe, test } from "node:test"; +import { strict as assert } from "node:assert"; +import { formatDiscoveryForTool, formatDiscoveryForCommand } from "../format.ts"; +import type { DiscoveryResult } from "../types.ts"; + +const emptyResult: DiscoveryResult = { + tools: [], + allItems: [], + summary: { + mcpServers: 0, + rules: 0, + contextFiles: 0, + settings: 0, + totalItems: 0, + toolsScanned: 8, + toolsWithConfig: 0, + }, + warnings: [], + durationMs: 42, +}; + +const populatedResult: DiscoveryResult = { + tools: [ + { + tool: { id: "cursor", name: "Cursor", userDir: ".cursor", projectDir: ".cursor" }, + items: [ + { + type: "mcp-server", + name: "test-mcp", + command: "node", + args: ["server.js"], + transport: "stdio", + source: { tool: "cursor", toolName: "Cursor", path: "/project/.cursor/mcp.json", level: "project" }, + }, + { + type: "rule", + name: "style", + content: "Use semicolons and strict TypeScript.", + alwaysApply: true, + source: { tool: "cursor", toolName: "Cursor", path: "/project/.cursor/rules/style.mdc", level: "project" }, + }, + ], + warnings: [], + }, + { + tool: { id: "github-copilot", name: "GitHub Copilot", userDir: null, projectDir: ".github" }, + items: [ + { + type: "context-file", + name: "copilot-instructions.md", + content: "Be helpful.", + source: { tool: "github-copilot", toolName: "GitHub Copilot", path: "/project/.github/copilot-instructions.md", level: "project" }, + }, + ], + warnings: [], + }, + ], + allItems: [], + summary: { + mcpServers: 1, + rules: 1, + contextFiles: 1, + settings: 0, + totalItems: 3, + toolsScanned: 8, + toolsWithConfig: 2, + }, + warnings: [], + durationMs: 15, +}; +populatedResult.allItems = populatedResult.tools.flatMap((t) => t.items); + +describe("formatDiscoveryForTool", () => { + test("formats empty result", () => { + const text = formatDiscoveryForTool(emptyResult); + assert.ok(text.includes("0/8 tools with config")); + assert.ok(text.includes("No configuration found")); + }); + + test("formats populated result with sections", () => { + const text = formatDiscoveryForTool(populatedResult); + assert.ok(text.includes("2/8 tools with config")); + assert.ok(text.includes("1 MCP server(s)")); + assert.ok(text.includes("Cursor")); + assert.ok(text.includes("test-mcp")); + assert.ok(text.includes("GitHub Copilot")); + assert.ok(text.includes("copilot-instructions.md")); + }); +}); + +describe("formatDiscoveryForCommand", () => { + test("formats empty result", () => { + const lines = formatDiscoveryForCommand(emptyResult); + const text = lines.join("\n"); + assert.ok(text.includes("0 of 8")); + assert.ok(text.includes("No configuration found")); + }); + + test("formats populated result as summary", () => { + const lines = formatDiscoveryForCommand(populatedResult); + const text = lines.join("\n"); + assert.ok(text.includes("2 of 8")); + assert.ok(text.includes("Cursor")); + assert.ok(text.includes("MCP: test-mcp")); + }); +}); diff --git a/src/resources/extensions/universal-config/tests/scanners.test.ts b/src/resources/extensions/universal-config/tests/scanners.test.ts new file mode 100644 index 000000000..f31c8452a --- /dev/null +++ b/src/resources/extensions/universal-config/tests/scanners.test.ts @@ -0,0 +1,438 @@ +/** + * Tests for universal config discovery scanners. + * + * Uses temporary directories to simulate config layouts from each tool. + * Runs with: node --experimental-strip-types --test + */ + +import { describe, test } from "node:test"; +import { strict as assert } from "node:assert"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { SCANNERS } from "../scanners.ts"; +import { TOOLS } from "../tools.ts"; +import type { ToolInfo, DiscoveredItem } from "../types.ts"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getTool(id: string): ToolInfo { + const tool = TOOLS.find((t) => t.id === id); + if (!tool) throw new Error(`Unknown tool: ${id}`); + return tool; +} + +function mkdirp(path: string) { + mkdirSync(path, { recursive: true }); +} + +function writeJson(path: string, data: unknown) { + mkdirp(join(path, "..")); + writeFileSync(path, JSON.stringify(data, null, 2), "utf8"); +} + +function writeText(path: string, content: string) { + mkdirp(join(path, "..")); + writeFileSync(path, content, "utf8"); +} + +function makeTempDirs(): { testRoot: string; testHome: string; cleanup: () => void } { + const base = join(tmpdir(), `ucd-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + const testRoot = join(base, "project"); + const testHome = join(base, "home"); + mkdirp(testRoot); + mkdirp(testHome); + return { + testRoot, + testHome, + cleanup: () => rmSync(base, { recursive: true, force: true }), + }; +} + +// ── Claude Code ─────────────────────────────────────────────────────────────── + +describe("Claude Code scanner", () => { + test("discovers MCP servers from ~/.claude.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testHome, ".claude.json"), { + mcpServers: { + "test-server": { command: "npx", args: ["-y", "test-mcp"], type: "stdio" }, + }, + }); + + const { items } = await SCANNERS.claude(testRoot, testHome, getTool("claude")); + const servers = items.filter((i) => i.type === "mcp-server"); + assert.equal(servers.length, 1); + assert.equal(servers[0]!.type, "mcp-server"); + if (servers[0]!.type === "mcp-server") { + assert.equal(servers[0]!.name, "test-server"); + assert.equal(servers[0]!.command, "npx"); + assert.equal(servers[0]!.transport, "stdio"); + assert.equal(servers[0]!.source.level, "user"); + } + } finally { + cleanup(); + } + }); + + test("discovers project MCP from .claude/.mcp.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".claude/.mcp.json"), { + mcpServers: { "project-server": { command: "node", args: ["server.js"] } }, + }); + + const { items } = await SCANNERS.claude(testRoot, testHome, getTool("claude")); + const servers = items.filter((i) => i.type === "mcp-server"); + assert.equal(servers.length, 1); + assert.equal(servers[0]!.source.level, "project"); + } finally { + cleanup(); + } + }); + + test("discovers CLAUDE.md context files", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testHome, ".claude/CLAUDE.md"), "# User instructions"); + writeText(join(testRoot, "CLAUDE.md"), "# Project root instructions"); + writeText(join(testRoot, ".claude/CLAUDE.md"), "# Project .claude instructions"); + + const { items } = await SCANNERS.claude(testRoot, testHome, getTool("claude")); + const contexts = items.filter((i) => i.type === "context-file"); + assert.equal(contexts.length, 3); + } finally { + cleanup(); + } + }); + + test("discovers settings.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testHome, ".claude/settings.json"), { theme: "dark" }); + + const { items } = await SCANNERS.claude(testRoot, testHome, getTool("claude")); + const settings = items.filter((i) => i.type === "settings"); + assert.equal(settings.length, 1); + } finally { + cleanup(); + } + }); + + test("handles missing files gracefully", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + const { items, warnings } = await SCANNERS.claude(testRoot, testHome, getTool("claude")); + assert.equal(items.length, 0); + assert.equal(warnings.length, 0); + } finally { + cleanup(); + } + }); +}); + +// ── Cursor ──────────────────────────────────────────────────────────────────── + +describe("Cursor scanner", () => { + test("discovers MCP servers from .cursor/mcp.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".cursor/mcp.json"), { + mcpServers: { "cursor-mcp": { command: "python", args: ["mcp.py"], type: "stdio" } }, + }); + + const { items } = await SCANNERS.cursor(testRoot, testHome, getTool("cursor")); + const servers = items.filter((i) => i.type === "mcp-server"); + assert.equal(servers.length, 1); + if (servers[0]!.type === "mcp-server") { + assert.equal(servers[0]!.name, "cursor-mcp"); + assert.equal(servers[0]!.command, "python"); + } + } finally { + cleanup(); + } + }); + + test("discovers rules from .cursor/rules/*.mdc", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText( + join(testRoot, ".cursor/rules/coding-style.mdc"), + "---\ndescription: Coding style rules\nalwaysApply: true\n---\nUse TypeScript strict mode.", + ); + + const { items } = await SCANNERS.cursor(testRoot, testHome, getTool("cursor")); + const rules = items.filter((i) => i.type === "rule"); + assert.equal(rules.length, 1); + if (rules[0]!.type === "rule") { + assert.equal(rules[0]!.name, "coding-style"); + assert.equal(rules[0]!.alwaysApply, true); + assert.equal(rules[0]!.description, "Coding style rules"); + } + } finally { + cleanup(); + } + }); + + test("discovers legacy .cursorrules", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testRoot, ".cursorrules"), "Always use semicolons."); + + const { items } = await SCANNERS.cursor(testRoot, testHome, getTool("cursor")); + const rules = items.filter((i) => i.type === "rule"); + assert.equal(rules.length, 1); + if (rules[0]!.type === "rule") { + assert.equal(rules[0]!.content, "Always use semicolons."); + } + } finally { + cleanup(); + } + }); +}); + +// ── Windsurf ────────────────────────────────────────────────────────────────── + +describe("Windsurf scanner", () => { + test("discovers MCP servers from mcp_config.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".windsurf/mcp_config.json"), { + mcpServers: { "ws-server": { command: "node", args: ["ws.js"] } }, + }); + + const { items } = await SCANNERS.windsurf(testRoot, testHome, getTool("windsurf")); + const servers = items.filter((i) => i.type === "mcp-server"); + assert.equal(servers.length, 1); + } finally { + cleanup(); + } + }); + + test("discovers global rules from user dir", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testHome, ".codeium/windsurf/memories/global_rules.md"), "Be concise."); + + const { items } = await SCANNERS.windsurf(testRoot, testHome, getTool("windsurf")); + const rules = items.filter((i) => i.type === "rule"); + assert.equal(rules.length, 1); + if (rules[0]!.type === "rule") { + assert.equal(rules[0]!.name, "global_rules"); + assert.equal(rules[0]!.alwaysApply, true); + } + } finally { + cleanup(); + } + }); +}); + +// ── Gemini CLI ──────────────────────────────────────────────────────────────── + +describe("Gemini CLI scanner", () => { + test("discovers MCP servers from settings.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".gemini/settings.json"), { + mcpServers: { "gemini-mcp": { command: "deno", args: ["run", "mcp.ts"] } }, + }); + + const { items } = await SCANNERS.gemini(testRoot, testHome, getTool("gemini")); + const servers = items.filter((i) => i.type === "mcp-server"); + assert.equal(servers.length, 1); + } finally { + cleanup(); + } + }); + + test("discovers GEMINI.md context files", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testHome, ".gemini/GEMINI.md"), "User gemini instructions"); + writeText(join(testRoot, ".gemini/GEMINI.md"), "Project gemini instructions"); + + const { items } = await SCANNERS.gemini(testRoot, testHome, getTool("gemini")); + const contexts = items.filter((i) => i.type === "context-file"); + assert.equal(contexts.length, 2); + } finally { + cleanup(); + } + }); +}); + +// ── Codex ───────────────────────────────────────────────────────────────────── + +describe("Codex scanner", () => { + test("discovers AGENTS.md from user dir", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testHome, ".codex/AGENTS.md"), "Codex user instructions"); + + const { items } = await SCANNERS.codex(testRoot, testHome, getTool("codex")); + const contexts = items.filter((i) => i.type === "context-file"); + assert.equal(contexts.length, 1); + if (contexts[0]!.type === "context-file") { + assert.equal(contexts[0]!.name, "AGENTS.md (user)"); + } + } finally { + cleanup(); + } + }); + + test("warns about TOML config", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testHome, ".codex/config.toml"), "[mcp_servers.test]\ncommand = 'node'"); + + const { warnings } = await SCANNERS.codex(testRoot, testHome, getTool("codex")); + assert.ok(warnings.length > 0); + assert.ok(warnings[0]!.includes("TOML")); + } finally { + cleanup(); + } + }); +}); + +// ── Cline ───────────────────────────────────────────────────────────────────── + +describe("Cline scanner", () => { + test("discovers .clinerules as single file", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testRoot, ".clinerules"), "Follow TDD."); + + const { items } = await SCANNERS.cline(testRoot, testHome, getTool("cline")); + const rules = items.filter((i) => i.type === "rule"); + assert.equal(rules.length, 1); + if (rules[0]!.type === "rule") { + assert.equal(rules[0]!.name, "clinerules"); + assert.equal(rules[0]!.alwaysApply, true); + } + } finally { + cleanup(); + } + }); + + test("discovers .clinerules as directory", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + mkdirp(join(testRoot, ".clinerules")); + writeText(join(testRoot, ".clinerules/style.md"), "Use 2-space indent."); + writeText(join(testRoot, ".clinerules/testing.md"), "Write tests first."); + + const { items } = await SCANNERS.cline(testRoot, testHome, getTool("cline")); + const rules = items.filter((i) => i.type === "rule"); + assert.equal(rules.length, 2); + } finally { + cleanup(); + } + }); + + test("handles missing .clinerules", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + const { items } = await SCANNERS.cline(testRoot, testHome, getTool("cline")); + assert.equal(items.length, 0); + } finally { + cleanup(); + } + }); +}); + +// ── GitHub Copilot ──────────────────────────────────────────────────────────── + +describe("GitHub Copilot scanner", () => { + test("discovers copilot-instructions.md", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testRoot, ".github/copilot-instructions.md"), "Use TypeScript."); + + const { items } = await SCANNERS["github-copilot"](testRoot, testHome, getTool("github-copilot")); + const contexts = items.filter((i) => i.type === "context-file"); + assert.equal(contexts.length, 1); + if (contexts[0]!.type === "context-file") { + assert.equal(contexts[0]!.name, "copilot-instructions.md"); + } + } finally { + cleanup(); + } + }); + + test("discovers .instructions.md files with frontmatter", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText( + join(testRoot, ".github/instructions/react.instructions.md"), + '---\napplyTo: "**/*.tsx"\n---\nUse React functional components.', + ); + + const { items } = await SCANNERS["github-copilot"](testRoot, testHome, getTool("github-copilot")); + const rules = items.filter((i) => i.type === "rule"); + assert.equal(rules.length, 1); + if (rules[0]!.type === "rule") { + assert.equal(rules[0]!.name, "react"); + assert.deepEqual(rules[0]!.globs, ["**/*.tsx"]); + } + } finally { + cleanup(); + } + }); +}); + +// ── VS Code ─────────────────────────────────────────────────────────────────── + +describe("VS Code scanner", () => { + test("discovers settings.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".vscode/settings.json"), { + "editor.fontSize": 14, + }); + + const { items } = await SCANNERS.vscode(testRoot, testHome, getTool("vscode")); + const settings = items.filter((i) => i.type === "settings"); + assert.equal(settings.length, 1); + } finally { + cleanup(); + } + }); + + test("discovers MCP servers from .vscode/mcp.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".vscode/mcp.json"), { + servers: { "vscode-mcp": { command: "node", args: ["mcp.js"] } }, + }); + + const { items } = await SCANNERS.vscode(testRoot, testHome, getTool("vscode")); + const servers = items.filter((i) => i.type === "mcp-server"); + assert.equal(servers.length, 1); + if (servers[0]!.type === "mcp-server") { + assert.equal(servers[0]!.name, "vscode-mcp"); + } + } finally { + cleanup(); + } + }); + + test("discovers MCP servers embedded in settings.json", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeJson(join(testRoot, ".vscode/settings.json"), { + "mcp.servers": { + "embedded-mcp": { command: "python", args: ["-m", "mcp_server"] }, + }, + }); + + const { items } = await SCANNERS.vscode(testRoot, testHome, getTool("vscode")); + const servers = items.filter((i) => i.type === "mcp-server"); + assert.equal(servers.length, 1); + if (servers[0]!.type === "mcp-server") { + assert.equal(servers[0]!.name, "embedded-mcp"); + } + } finally { + cleanup(); + } + }); +}); diff --git a/src/resources/extensions/universal-config/tools.ts b/src/resources/extensions/universal-config/tools.ts new file mode 100644 index 000000000..c59f4e19e --- /dev/null +++ b/src/resources/extensions/universal-config/tools.ts @@ -0,0 +1,60 @@ +/** + * Universal Config Discovery — tool registry + * + * Known AI coding tools with their config directory locations. + * Based on research of Oh My Pi's discovery system and direct config + * file inspection of each tool. + */ + +import type { ToolInfo } from "./types.js"; + +export const TOOLS: ToolInfo[] = [ + { + id: "claude", + name: "Claude Code", + userDir: ".claude", + projectDir: ".claude", + }, + { + id: "cursor", + name: "Cursor", + userDir: ".cursor", + projectDir: ".cursor", + }, + { + id: "windsurf", + name: "Windsurf", + userDir: ".codeium/windsurf", + projectDir: ".windsurf", + }, + { + id: "gemini", + name: "Gemini CLI", + userDir: ".gemini", + projectDir: ".gemini", + }, + { + id: "codex", + name: "OpenAI Codex", + userDir: ".codex", + projectDir: ".codex", + }, + { + id: "cline", + name: "Cline", + userDir: null, + projectDir: null, // Uses root-level .clinerules (handled specially) + }, + { + id: "github-copilot", + name: "GitHub Copilot", + userDir: null, + projectDir: ".github", + }, + { + id: "vscode", + name: "VS Code", + userDir: null, + projectDir: ".vscode", + }, +]; diff --git a/src/resources/extensions/universal-config/types.ts b/src/resources/extensions/universal-config/types.ts new file mode 100644 index 000000000..3f3eeae37 --- /dev/null +++ b/src/resources/extensions/universal-config/types.ts @@ -0,0 +1,116 @@ +/** + * Universal Config Discovery — shared types + * + * Normalized schema for discovered configuration items from all supported + * AI coding tools: Claude Code, Cursor, Windsurf, Gemini CLI, Codex, + * Cline, GitHub Copilot, VS Code. + */ + +// ── Source metadata ─────────────────────────────────────────────────────────── + +export type ConfigLevel = "user" | "project"; + +export interface ConfigSource { + /** Which tool this config came from */ + tool: ToolId; + /** Display name of the tool */ + toolName: string; + /** Absolute path to the config file */ + path: string; + /** User-level (~/) or project-level (./) */ + level: ConfigLevel; +} + +// ── Tool identifiers ────────────────────────────────────────────────────────── + +export type ToolId = + | "claude" + | "cursor" + | "windsurf" + | "gemini" + | "codex" + | "cline" + | "github-copilot" + | "vscode"; + +export interface ToolInfo { + id: ToolId; + name: string; + /** User-level config base directory relative to $HOME (null = no user config) */ + userDir: string | null; + /** Project-level config directory name relative to project root (null = no project config) */ + projectDir: string | null; +} + +// ── Discovered config items ─────────────────────────────────────────────────── + +export interface DiscoveredMCPServer { + type: "mcp-server"; + name: string; + command?: string; + args?: string[]; + env?: Record; + url?: string; + transport?: "stdio" | "sse" | "http"; + source: ConfigSource; +} + +export interface DiscoveredRule { + type: "rule"; + name: string; + content: string; + /** Glob patterns this rule applies to */ + globs?: string[]; + /** Whether the rule applies to all files */ + alwaysApply?: boolean; + description?: string; + source: ConfigSource; +} + +export interface DiscoveredContextFile { + type: "context-file"; + name: string; + content: string; + source: ConfigSource; +} + +export interface DiscoveredSettings { + type: "settings"; + data: Record; + source: ConfigSource; +} + +export type DiscoveredItem = + | DiscoveredMCPServer + | DiscoveredRule + | DiscoveredContextFile + | DiscoveredSettings; + +// ── Discovery result ────────────────────────────────────────────────────────── + +export interface ToolDiscoveryResult { + tool: ToolInfo; + items: DiscoveredItem[]; + warnings: string[]; +} + +export interface DiscoveryResult { + /** All discovered items grouped by tool */ + tools: ToolDiscoveryResult[]; + /** Flat list of all discovered items */ + allItems: DiscoveredItem[]; + /** Summary counts by category */ + summary: { + mcpServers: number; + rules: number; + contextFiles: number; + settings: number; + totalItems: number; + toolsScanned: number; + toolsWithConfig: number; + }; + /** Warnings from scanners */ + warnings: string[]; + /** Duration in milliseconds */ + durationMs: number; +}