Merge pull request #217 from gsd-build/feat/universal-config-discovery-v2

feat: universal config discovery from 8 AI coding tools
This commit is contained in:
TÂCHES 2026-03-13 12:08:28 -06:00 committed by GitHub
commit 05868558e7
10 changed files with 1782 additions and 0 deletions

View file

@ -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<DiscoveryResult> {
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,
};
}

View file

@ -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<string, DiscoveredItem[]> {
const groups: Record<string, DiscoveredItem[]> = {};
for (const item of items) {
(groups[item.type] ??= []).push(item);
}
return groups;
}
function countByType(items: DiscoveredItem[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const item of items) {
counts[item.type] = (counts[item.type] ?? 0) + 1;
}
return counts;
}

View file

@ -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;
});
}

View file

@ -0,0 +1,11 @@
{
"name": "pi-extension-universal-config",
"private": true,
"version": "1.0.0",
"type": "module",
"pi": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -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<string | null> {
try {
return await readFile(path, "utf8");
} catch {
return null;
}
}
function tryParseJson<T>(content: string): T | null {
try {
return JSON.parse(content) as T;
} catch {
return null;
}
}
async function fileExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
async function isDirectory(path: string): Promise<boolean> {
try {
const s = await stat(path);
return s.isDirectory();
} catch {
return false;
}
}
async function readDirSafe(dir: string): Promise<string[]> {
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<string, unknown>; 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<string, unknown> = {};
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<string, unknown>,
filePath: string,
tool: ToolInfo,
level: ConfigLevel,
): DiscoveredMCPServer[] {
const servers: DiscoveredMCPServer[] = [];
const mcpServers = json.mcpServers as Record<string, unknown> | 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<string, unknown>;
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<string, string>) : 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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<string, unknown>)?.servers) as Record<string, unknown> | 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<Record<string, unknown>>(mcpContent);
if (json) {
// VS Code uses { servers: { ... } } or { mcpServers: { ... } }
const servers = (json.servers ?? json.mcpServers) as Record<string, unknown> | undefined;
if (servers && typeof servers === "object") {
items.push(...parseMcpServersFromJson({ mcpServers: servers }, mcpPath, tool, "project"));
}
}
}
return { items, warnings };
}
// ── Scanner registry ──────────────────────────────────────────────────────────
export const SCANNERS: Record<ToolId, Scanner> = {
claude: scanClaude,
cursor: scanCursor,
windsurf: scanWindsurf,
gemini: scanGemini,
codex: scanCodex,
cline: scanCline,
"github-copilot": scanGithubCopilot,
vscode: scanVSCode,
};

View file

@ -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();
}
});
});

View file

@ -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"));
});
});

View file

@ -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();
}
});
});

View file

@ -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",
},
];

View file

@ -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<string, string>;
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<string, unknown>;
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;
}