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:
commit
05868558e7
10 changed files with 1782 additions and 0 deletions
78
src/resources/extensions/universal-config/discovery.ts
Normal file
78
src/resources/extensions/universal-config/discovery.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
160
src/resources/extensions/universal-config/format.ts
Normal file
160
src/resources/extensions/universal-config/format.ts
Normal 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;
|
||||
}
|
||||
118
src/resources/extensions/universal-config/index.ts
Normal file
118
src/resources/extensions/universal-config/index.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
11
src/resources/extensions/universal-config/package.json
Normal file
11
src/resources/extensions/universal-config/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "pi-extension-universal-config",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
579
src/resources/extensions/universal-config/scanners.ts
Normal file
579
src/resources/extensions/universal-config/scanners.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
111
src/resources/extensions/universal-config/tests/format.test.ts
Normal file
111
src/resources/extensions/universal-config/tests/format.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
438
src/resources/extensions/universal-config/tests/scanners.test.ts
Normal file
438
src/resources/extensions/universal-config/tests/scanners.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
60
src/resources/extensions/universal-config/tools.ts
Normal file
60
src/resources/extensions/universal-config/tools.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
116
src/resources/extensions/universal-config/types.ts
Normal file
116
src/resources/extensions/universal-config/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue