From 2e3fa903b1e317b3a71ea9ec0283075b337ee999 Mon Sep 17 00:00:00 2001 From: Jamie McGregor Nelson Date: Sun, 15 Mar 2026 09:03:28 -0400 Subject: [PATCH] feat: import Claude marketplace plugins with namespaced components --- src/resources/extensions/gsd/claude-import.ts | 640 ++++++++ .../extensions/gsd/collision-diagnostics.ts | 332 ++++ src/resources/extensions/gsd/commands.ts | 47 +- .../extensions/gsd/marketplace-discovery.ts | 502 ++++++ .../extensions/gsd/namespaced-registry.ts | 467 ++++++ .../extensions/gsd/namespaced-resolver.ts | 307 ++++ .../extensions/gsd/plugin-importer.ts | 410 +++++ .../gsd/tests/claude-import-tui.test.ts | 314 ++++ .../gsd/tests/collision-diagnostics.test.ts | 705 +++++++++ .../gsd/tests/marketplace-discovery.test.ts | 197 +++ .../gsd/tests/marketplace-test-fixtures.ts | 91 ++ .../gsd/tests/namespaced-registry.test.ts | 1027 ++++++++++++ .../gsd/tests/namespaced-resolver.test.ts | 671 ++++++++ .../gsd/tests/plugin-importer-live.test.ts | 479 ++++++ .../gsd/tests/plugin-importer.test.ts | 1383 +++++++++++++++++ .../extensions/universal-config/discovery.ts | 4 + .../extensions/universal-config/format.ts | 31 +- .../extensions/universal-config/index.ts | 8 +- .../extensions/universal-config/scanners.ts | 63 + .../universal-config/tests/discovery.test.ts | 10 +- .../universal-config/tests/format.test.ts | 28 +- .../universal-config/tests/scanners.test.ts | 18 + .../extensions/universal-config/types.ts | 21 +- src/tests/marketplace-discovery.test.ts | 405 +++++ 24 files changed, 8142 insertions(+), 18 deletions(-) create mode 100644 src/resources/extensions/gsd/claude-import.ts create mode 100644 src/resources/extensions/gsd/collision-diagnostics.ts create mode 100644 src/resources/extensions/gsd/marketplace-discovery.ts create mode 100644 src/resources/extensions/gsd/namespaced-registry.ts create mode 100644 src/resources/extensions/gsd/namespaced-resolver.ts create mode 100644 src/resources/extensions/gsd/plugin-importer.ts create mode 100644 src/resources/extensions/gsd/tests/claude-import-tui.test.ts create mode 100644 src/resources/extensions/gsd/tests/collision-diagnostics.test.ts create mode 100644 src/resources/extensions/gsd/tests/marketplace-discovery.test.ts create mode 100644 src/resources/extensions/gsd/tests/marketplace-test-fixtures.ts create mode 100644 src/resources/extensions/gsd/tests/namespaced-registry.test.ts create mode 100644 src/resources/extensions/gsd/tests/namespaced-resolver.test.ts create mode 100644 src/resources/extensions/gsd/tests/plugin-importer-live.test.ts create mode 100644 src/resources/extensions/gsd/tests/plugin-importer.test.ts create mode 100644 src/tests/marketplace-discovery.test.ts diff --git a/src/resources/extensions/gsd/claude-import.ts b/src/resources/extensions/gsd/claude-import.ts new file mode 100644 index 000000000..df48d2fab --- /dev/null +++ b/src/resources/extensions/gsd/claude-import.ts @@ -0,0 +1,640 @@ +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { SettingsManager, getAgentDir } from "@gsd/pi-coding-agent"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { homedir } from "node:os"; +import { PluginImporter, type ImportManifestEntry } from "./plugin-importer.js"; +import type { NamespacedComponent } from "./namespaced-registry.js"; + +export interface ClaudeSkillCandidate { + type: "skill"; + name: string; + path: string; + root: string; + sourceLabel: string; +} + +export interface ClaudePluginCandidate { + type: "plugin"; + name: string; + path: string; + root: string; + sourceLabel: string; + packageName?: string; +} + +const SKIP_DIRS = new Set([ + ".git", + "node_modules", + ".worktrees", + "dist", + "build", + ".next", + ".turbo", + "cache", + ".cache", +]); + +function uniqueExistingDirs(paths: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const candidate of paths) { + const resolvedPath = resolve(candidate); + if (seen.has(resolvedPath)) continue; + seen.add(resolvedPath); + if (existsSync(resolvedPath)) out.push(resolvedPath); + } + return out; +} + +export function getClaudeSearchRoots(cwd: string): { skillRoots: string[]; pluginRoots: string[] } { + const home = homedir(); + const parent = resolve(cwd, ".."); + const grandparent = resolve(cwd, "..", ".."); + + const skillRoots = uniqueExistingDirs([ + join(home, ".claude", "skills"), + join(home, "repos", "claude_skills"), + join(home, "repos", "skills"), + join(parent, "claude_skills"), + join(parent, "skills"), + join(grandparent, "claude_skills"), + join(grandparent, "skills"), + ]); + + const pluginRoots = uniqueExistingDirs([ + join(home, ".claude", "plugins"), + join(home, "repos", "claude-plugins-official"), + join(home, "repos", "claude_skills"), + join(parent, "claude-plugins-official"), + join(parent, "claude_skills"), + join(grandparent, "claude-plugins-official"), + join(grandparent, "claude_skills"), + ]); + + return { skillRoots, pluginRoots }; +} + +function sourceLabel(path: string): string { + const home = homedir(); + if (path.startsWith(join(home, ".claude"))) return "claude-home"; + if (path.startsWith(join(home, "repos"))) return "repos"; + return "local"; +} + +/** + * Check if a path is a marketplace directory (contains .claude-plugin/marketplace.json). + * Marketplace paths use the PluginImporter flow; non-marketplace use the legacy flat flow. + */ +function isMarketplacePath(pluginPath: string): boolean { + const marketplaceJson = join(pluginPath, ".claude-plugin", "marketplace.json"); + return existsSync(marketplaceJson); +} + +/** + * Detect which plugin roots are marketplaces and which are legacy flat paths. + */ +function categorizePluginRoots(pluginRoots: string[]): { marketplaces: string[]; flat: string[] } { + const marketplaces: string[] = []; + const flat: string[] = []; + + for (const root of pluginRoots) { + if (isMarketplacePath(root)) { + marketplaces.push(root); + } else { + flat.push(root); + } + } + + return { marketplaces, flat }; +} + +function walkDirs(root: string, visit: (dir: string, depth: number) => void, maxDepth = 4): void { + function walk(dir: string, depth: number) { + visit(dir, depth); + if (depth >= maxDepth) return; + let entries: Array<{ name: string; isDirectory: () => boolean }> = []; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (SKIP_DIRS.has(entry.name)) continue; + walk(join(dir, entry.name), depth + 1); + } + } + walk(root, 0); +} + +export function discoverClaudeSkills(cwd: string): ClaudeSkillCandidate[] { + const { skillRoots } = getClaudeSearchRoots(cwd); + const results: ClaudeSkillCandidate[] = []; + const seen = new Set(); + + for (const root of skillRoots) { + walkDirs(root, (dir) => { + const skillFile = join(dir, "SKILL.md"); + if (!existsSync(skillFile)) return; + const resolvedDir = resolve(dir); + if (seen.has(resolvedDir)) return; + seen.add(resolvedDir); + results.push({ + type: "skill", + name: basename(dir), + path: resolvedDir, + root, + sourceLabel: sourceLabel(root), + }); + }, 5); + } + + return results.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)); +} + +export function discoverClaudePlugins(cwd: string): ClaudePluginCandidate[] { + const { pluginRoots } = getClaudeSearchRoots(cwd); + const results: ClaudePluginCandidate[] = []; + const seen = new Set(); + + for (const root of pluginRoots) { + walkDirs(root, (dir) => { + const pkgPath = join(dir, "package.json"); + if (!existsSync(pkgPath)) return; + const resolvedDir = resolve(dir); + if (seen.has(resolvedDir)) return; + seen.add(resolvedDir); + let packageName: string | undefined; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string }; + packageName = pkg.name; + } catch { + packageName = undefined; + } + results.push({ + type: "plugin", + name: packageName || basename(dir), + packageName, + path: resolvedDir, + root, + sourceLabel: sourceLabel(root), + }); + }, 4); + } + + return results.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)); +} + +async function chooseMany( + ctx: ExtensionCommandContext, + title: string, + candidates: T[], +): Promise { + if (candidates.length === 0) return []; + + const mode = await ctx.ui.select(`${title} (${candidates.length} found)`, [ + "Import all discovered", + "Select individually", + "Cancel", + ]); + + if (!mode || mode === "Cancel") return []; + if (mode === "Import all discovered") return candidates; + + const remaining = [...candidates]; + const selected: T[] = []; + while (remaining.length > 0) { + const options = [ + ...remaining.map((item) => `${item.name} — ${item.sourceLabel} — ${relative(item.root, item.path) || "."}`), + "Done selecting", + ]; + const picked = await ctx.ui.select(`${title}: choose an item`, options); + if (!picked || picked === "Done selecting") break; + const idx = options.indexOf(picked); + if (idx < 0 || idx >= remaining.length) break; + selected.push(remaining[idx]!); + remaining.splice(idx, 1); + } + return selected; +} + +function mergeStringList(existing: unknown, additions: string[]): string[] { + const list = Array.isArray(existing) ? existing.filter((v): v is string => typeof v === "string") : []; + const seen = new Set(list); + for (const item of additions) { + if (!seen.has(item)) { + list.push(item); + seen.add(item); + } + } + return list; +} + +function mergePackageSources(existing: unknown, additions: string[]): Array { + const current = Array.isArray(existing) + ? existing.filter((v): v is string | { source: string } => typeof v === "string" || (typeof v === "object" && v !== null && typeof (v as { source?: unknown }).source === "string")) + : []; + + const seen = new Set(current.map((entry) => typeof entry === "string" ? entry : entry.source)); + const merged = [...current]; + for (const add of additions) { + if (!seen.has(add)) { + merged.push(add); + seen.add(add); + } + } + return merged; +} + +// ============================================================================ +// Marketplace PluginImporter Integration (T02) +// ============================================================================ + +/** + * Component candidate from marketplace discovery. + * Extends NamespacedComponent with UI-friendly fields. + */ +interface MarketplaceComponentCandidate { + component: NamespacedComponent; + displayName: string; + pluginName: string; +} + +/** + * Format a component for display in selection UI. + */ +function formatComponentForSelection(comp: NamespacedComponent): string { + const typeLabel = comp.type === 'skill' ? '🔧' : '🤖'; + const nsLabel = comp.namespace ? `${comp.namespace}:` : ''; + return `${typeLabel} ${nsLabel}${comp.name}`; +} + +/** + * Present marketplace components for user selection, grouped by plugin. + * Returns the selected components for import. + */ +async function selectMarketplaceComponents( + ctx: ExtensionCommandContext, + importer: PluginImporter, + scope: "global" | "project" +): Promise { + const plugins = importer.getDiscoveredPlugins(); + + if (plugins.length === 0) { + ctx.ui.notify("No plugins discovered in marketplace.", "info"); + return []; + } + + // Build component candidates grouped by plugin + const allComponents: MarketplaceComponentCandidate[] = []; + for (const plugin of plugins) { + const components = importer.selectComponents(c => c.namespace === plugin.canonicalName); + for (const comp of components) { + allComponents.push({ + component: comp, + displayName: formatComponentForSelection(comp), + pluginName: plugin.canonicalName, + }); + } + } + + if (allComponents.length === 0) { + ctx.ui.notify("No components (skills/agents) found in marketplace plugins.", "info"); + return []; + } + + // Ask user for selection mode + const mode = await ctx.ui.select( + `Marketplace components → ${scope} config (${allComponents.length} found across ${plugins.length} plugins)`, + [ + "Import all components", + "Select by plugin", + "Select individually", + "Cancel", + ] + ); + + if (!mode || mode === "Cancel") return []; + + if (mode === "Import all components") { + return allComponents.map(c => c.component); + } + + if (mode === "Select by plugin") { + // Let user select plugins, then import all their components + const pluginNames = plugins.map(p => p.canonicalName); + const selectedPluginNames: string[] = []; + + while (true) { + const remaining = pluginNames.filter(n => !selectedPluginNames.includes(n)); + if (remaining.length === 0) break; + + const options = [...remaining, "Done selecting"]; + const picked = await ctx.ui.select("Select a plugin to import all its components", options); + + if (!picked || picked === "Done selecting") break; + selectedPluginNames.push(picked); + } + + return allComponents + .filter(c => selectedPluginNames.includes(c.pluginName)) + .map(c => c.component); + } + + // Select individually + const remaining = [...allComponents]; + const selected: NamespacedComponent[] = []; + + while (remaining.length > 0) { + const options = remaining.map(c => + `${c.displayName} — ${c.pluginName}` + ); + options.push("Done selecting"); + + const picked = await ctx.ui.select("Select a component to import", options); + if (!picked || picked === "Done selecting") break; + + const idx = options.indexOf(picked); + if (idx < 0 || idx >= remaining.length) break; + + selected.push(remaining[idx]!.component); + remaining.splice(idx, 1); + } + + return selected; +} + +/** + * Format diagnostics for display to user. + * Returns a human-readable summary string. + */ +function formatDiagnosticsForUser( + diagnostics: Array<{ severity: string; class: string; remediation: string; involvedCanonicalNames: string[] }> +): string { + const lines: string[] = []; + + const errors = diagnostics.filter(d => d.severity === 'error'); + const warnings = diagnostics.filter(d => d.severity === 'warning'); + + if (errors.length > 0) { + lines.push(`❌ ${errors.length} error(s) blocking import:`); + for (const err of errors) { + lines.push(` - ${err.class}: ${err.involvedCanonicalNames.join(', ')}`); + lines.push(` ${err.remediation}`); + } + } + + if (warnings.length > 0) { + lines.push(`⚠️ ${warnings.length} warning(s):`); + for (const warn of warnings) { + lines.push(` - ${warn.class}: ${warn.involvedCanonicalNames.join(', ')}`); + } + } + + return lines.join('\n'); +} + +/** + * Persist import manifest entries to settings. + * Maps manifest entries to the appropriate settings format. + */ +function persistManifestToSettings( + manifestEntries: ImportManifestEntry[], + settingsManager: SettingsManager, + scope: "global" | "project" +): void { + // Group entries by namespace for organized persistence + const skillPaths = manifestEntries + .filter(e => e.type === 'skill') + .map(e => e.filePath); + + const agentPaths = manifestEntries + .filter(e => e.type === 'agent') + .map(e => e.filePath); + + // For marketplace plugins, we also want to store plugin-level metadata + // Currently this adds component paths to skills/agents lists + // Future enhancement: store canonical names with metadata + + if (skillPaths.length > 0) { + if (scope === "project") { + settingsManager.setProjectSkillPaths( + mergeStringList(settingsManager.getProjectSettings().skills, skillPaths) + ); + } else { + settingsManager.setSkillPaths( + mergeStringList(settingsManager.getGlobalSettings().skills, skillPaths) + ); + } + } + + // Do not persist imported marketplace agents into settings.packages. + // Claude plugin agent directories contain markdown agent definitions, not loadable Pi + // extension packages. Writing `.../agents` paths into packages makes startup treat + // them as extension roots and produces module-load errors. + // + // For now, marketplace agents remain discoverable via the import manifest and + // canonical metadata, but are not persisted into package sources. +} + + +export async function runClaudeImportFlow( + ctx: ExtensionCommandContext, + scope: "global" | "project", + readPrefs: () => Record, + writePrefs: (prefs: Record) => Promise, +): Promise { + const cwd = process.cwd(); + const settingsManager = SettingsManager.create(cwd, getAgentDir()); + const { skillRoots, pluginRoots } = getClaudeSearchRoots(cwd); + + // Categorize plugin roots into marketplaces vs flat paths + const { marketplaces, flat } = categorizePluginRoots(pluginRoots); + + // Determine import mode + const assetChoice = await ctx.ui.select("Import Claude assets into GSD/Pi config", [ + "Skills + plugins", + "Skills only", + "Plugins only", + "Cancel", + ]); + if (!assetChoice || assetChoice === "Cancel") return; + + const importSkills = assetChoice !== "Plugins only"; + const importPlugins = assetChoice !== "Skills only"; + + // Track what we're importing + let importedSkillsCount = 0; + let importedPluginsCount = 0; + let importedMarketplaceComponents = 0; + const canonicalNamesPersisted: string[] = []; + + // ========== SKILLS (legacy flat flow) ========== + if (importSkills) { + const discoveredSkills = discoverClaudeSkills(cwd); + const selectedSkills = await chooseMany(ctx, `Claude skills → ${scope} preferences`, discoveredSkills); + + if (selectedSkills.length > 0) { + const prefMode = await ctx.ui.select("How should GSD treat the imported skills?", [ + "Always use when relevant", + "Prefer when relevant", + "Do not modify skill preferences", + ]); + + const prefs = readPrefs(); + const skillPaths = selectedSkills.map((skill) => skill.path); + if (prefMode === "Always use when relevant") { + prefs.always_use_skills = mergeStringList(prefs.always_use_skills, skillPaths); + } else if (prefMode === "Prefer when relevant") { + prefs.prefer_skills = mergeStringList(prefs.prefer_skills, skillPaths); + } + + await writePrefs(prefs); + + if (scope === "project") { + settingsManager.setProjectSkillPaths(mergeStringList(settingsManager.getProjectSettings().skills, skillPaths)); + } else { + settingsManager.setSkillPaths(mergeStringList(settingsManager.getGlobalSettings().skills, skillPaths)); + } + + importedSkillsCount = selectedSkills.length; + } + } + + // ========== MARKETPLACE PLUGINS (new PluginImporter flow) ========== + if (importPlugins && marketplaces.length > 0) { + const marketplaceChoice = await ctx.ui.select( + `Found ${marketplaces.length} marketplace(s). Import from marketplace?`, + [ + "Yes - discover plugins and select components", + "Skip marketplaces (use legacy plugin paths only)", + "Cancel", + ] + ); + + if (marketplaceChoice === "Yes - discover plugins and select components") { + // Instantiate PluginImporter and discover + const importer = new PluginImporter(); + const discovery = importer.discover(marketplaces); + + if (discovery.summary.totalPlugins > 0) { + // Present components for selection + const selectedComponents = await selectMarketplaceComponents(ctx, importer, scope); + + if (selectedComponents.length > 0) { + // Run validation (pre-import diagnostics) + const validation = importer.validateImport(selectedComponents); + + // Show diagnostics + if (validation.diagnostics.length > 0) { + const diagMessage = formatDiagnosticsForUser(validation.diagnostics); + ctx.ui.notify(diagMessage, validation.canProceed ? "warning" : "error"); + + // Block if errors exist + if (!validation.canProceed) { + ctx.ui.notify( + "Import blocked due to canonical name conflicts. Please resolve the errors above.", + "error" + ); + return; + } + + // Warn but allow proceed for warnings + const proceed = await ctx.ui.select( + "Warnings detected. Continue with import?", + ["Yes, continue", "Cancel"] + ); + if (proceed !== "Yes, continue") { + return; + } + } + + // Generate manifest and persist + const manifest = importer.getImportManifest(selectedComponents); + persistManifestToSettings(manifest.entries, settingsManager, scope); + + importedMarketplaceComponents = selectedComponents.length; + canonicalNamesPersisted.push(...manifest.entries.map(e => e.canonicalName)); + } + } else { + ctx.ui.notify(`No plugins discovered in ${marketplaces.length} marketplace(s).`, "info"); + } + } + } + + // ========== FLAT PLUGIN PATHS (legacy flow) ========== + if (importPlugins && flat.length > 0) { + // Use legacy discovery for non-marketplace paths + const discoveredPlugins: ClaudePluginCandidate[] = []; + const seen = new Set(); + + for (const root of flat) { + walkDirs(root, (dir) => { + const pkgPath = join(dir, "package.json"); + if (!existsSync(pkgPath)) return; + const resolvedDir = resolve(dir); + if (seen.has(resolvedDir)) return; + seen.add(resolvedDir); + let packageName: string | undefined; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string }; + packageName = pkg.name; + } catch { + packageName = undefined; + } + discoveredPlugins.push({ + type: "plugin", + name: packageName || basename(dir), + packageName, + path: resolvedDir, + root, + sourceLabel: sourceLabel(root), + }); + }, 4); + } + + const sortedPlugins = discoveredPlugins.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)); + const selectedPlugins = await chooseMany(ctx, `Claude plugins/packages → ${scope} Pi settings`, sortedPlugins); + + if (selectedPlugins.length > 0) { + const pluginPaths = selectedPlugins.map((plugin) => plugin.path); + if (scope === "project") { + settingsManager.setProjectPackages(mergePackageSources(settingsManager.getProjectSettings().packages, pluginPaths)); + } else { + settingsManager.setPackages(mergePackageSources(settingsManager.getGlobalSettings().packages, pluginPaths)); + } + importedPluginsCount = selectedPlugins.length; + } + } + + // ========== FINAL SUMMARY ========== + if (importedSkillsCount === 0 && importedPluginsCount === 0 && importedMarketplaceComponents === 0) { + ctx.ui.notify("Claude import cancelled or nothing selected.", "info"); + return; + } + + await ctx.waitForIdle(); + await ctx.reload(); + + const lines = [ + `Imported Claude assets into ${scope} config:`, + `- Skills (flat): ${importedSkillsCount}`, + `- Plugins (flat paths): ${importedPluginsCount}`, + `- Marketplace components: ${importedMarketplaceComponents}`, + ]; + if (importedSkillsCount > 0) { + lines.push(`- Skill paths added to Pi settings (${scope}) for availability`); + lines.push(`- Skill refs added to GSD preferences (${scope}) when selected`); + } + if (importedPluginsCount > 0) { + lines.push(`- Plugin/package paths added to Pi settings (${scope}) packages`); + } + if (importedMarketplaceComponents > 0) { + lines.push(`- Canonical names preserved: ${canonicalNamesPersisted.length} entries`); + if (canonicalNamesPersisted.length <= 10) { + lines.push(` Names: ${canonicalNamesPersisted.join(', ')}`); + } + } + ctx.ui.notify(lines.join("\n"), "info"); +} diff --git a/src/resources/extensions/gsd/collision-diagnostics.ts b/src/resources/extensions/gsd/collision-diagnostics.ts new file mode 100644 index 000000000..09ec44c12 --- /dev/null +++ b/src/resources/extensions/gsd/collision-diagnostics.ts @@ -0,0 +1,332 @@ +/** + * Collision Diagnostics Module + * + * Bridges NamespacedRegistry collision data and NamespacedResolver ambiguous + * resolution into a classified diagnostic taxonomy. Provides two functions: + * - analyzeCollisions: Scans registry and resolver state to produce classified diagnostics + * - doctorReport: Formats diagnostics into human-readable output with severity and remediation + * + * This module implements R010 (collision reporting) and R011 (doctor advice) for the + * namespaced component system. + */ + +import type { NamespacedRegistry, RegistryDiagnostic } from './namespaced-registry.js'; +import type { NamespacedResolver, ResolutionResult } from './namespaced-resolver.js'; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * Classification of collision type. + * - canonical-conflict: Two plugins registered the same canonical name (hard error) + * - shorthand-overlap: Same bare name exists in multiple namespaces (ambiguity) + * - alias-conflict: Alias shadows a canonical name or bare component name + */ +export type CollisionClass = 'canonical-conflict' | 'shorthand-overlap' | 'alias-conflict'; + +/** + * Severity level for diagnostics. + * - error: Hard collision that prevents correct resolution + * - warning: Ambiguity that may cause surprising behavior + */ +export type DiagnosticSeverity = 'error' | 'warning'; + +/** + * A classified diagnostic with full context for remediation. + */ +export interface ClassifiedDiagnostic { + /** The collision classification */ + class: CollisionClass; + + /** Severity level */ + severity: DiagnosticSeverity; + + /** All canonical names involved in the collision */ + involvedCanonicalNames: string[]; + + /** File paths to the conflicting components */ + filePaths: string[]; + + /** Human-readable remediation advice */ + remediation: string; + + /** Optional: the bare name causing ambiguity (shorthand-overlap only) */ + ambiguousBareName?: string; + + /** Optional: the alias string (alias-conflict only) */ + alias?: string; + + /** Optional: the canonical name the alias points to (alias-conflict only) */ + aliasTarget?: string; + + /** Optional: type of alias conflict */ + aliasConflictType?: 'shadows-canonical' | 'shadows-bare-name'; +} + +/** + * Doctor report with summary statistics and formatted entries. + */ +export interface DoctorReport { + /** Summary counts by class */ + summary: { + /** Total diagnostics */ + total: number; + /** Canonical conflicts (errors) */ + canonicalConflicts: number; + /** Shorthand overlaps (warnings) */ + shorthandOverlaps: number; + /** Alias conflicts (warnings) */ + aliasConflicts: number; + }; + + /** Formatted report entries */ + entries: string[]; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Analyze a registry and resolver to produce classified diagnostics. + * + * This function: + * 1. Reads registry.getDiagnostics() for canonical conflicts (→ error severity) + * 2. Groups registry.getAll() by bare component.name + * 3. For groups with 2+ entries, calls resolver.resolve(bareName) to confirm ambiguity + * 4. Produces warning diagnostics for ambiguous shorthand resolution + * + * @param registry - The namespaced registry to analyze + * @param resolver - The resolver to test ambiguity + * @returns Array of classified diagnostics + */ +export function analyzeCollisions( + registry: NamespacedRegistry, + resolver: NamespacedResolver +): ClassifiedDiagnostic[] { + const diagnostics: ClassifiedDiagnostic[] = []; + + // Step 1: Process canonical conflicts from registry diagnostics + const registryDiagnostics = registry.getDiagnostics(); + for (const diag of registryDiagnostics) { + if (diag.type === 'collision') { + diagnostics.push({ + class: 'canonical-conflict', + severity: 'error', + involvedCanonicalNames: [diag.collision.canonicalName], + filePaths: [diag.collision.winnerPath, diag.collision.loserPath], + remediation: `Canonical name "${diag.collision.canonicalName}" registered multiple times. ` + + `The first registration (${diag.collision.winnerSource ?? 'unknown source'}) ` + + `took precedence over subsequent registration (${diag.collision.loserSource ?? 'unknown source'}). ` + + `Rename one of the conflicting components to resolve.`, + }); + } + } + + // Step 2: Find shorthand overlaps by grouping components by bare name + const components = registry.getAll(); + const byBareName = new Map(); + + for (const component of components) { + const bareName = component.name; + if (!byBareName.has(bareName)) { + byBareName.set(bareName, []); + } + byBareName.get(bareName)!.push(component); + } + + // Step 3: For groups with 2+ entries, check if resolver confirms ambiguity + for (const [bareName, candidates] of byBareName) { + if (candidates.length >= 2) { + // Use resolver to confirm ambiguity + const result = resolver.resolve(bareName); + + if (result.resolution === 'ambiguous') { + // This is a shorthand overlap + const canonicalNames = candidates.map(c => c.canonicalName); + const filePaths = candidates.map(c => c.filePath); + + diagnostics.push({ + class: 'shorthand-overlap', + severity: 'warning', + involvedCanonicalNames: canonicalNames, + filePaths, + remediation: formatShorthandRemediation(bareName, canonicalNames), + ambiguousBareName: bareName, + }); + } + // If resolution is 'shorthand' or 'local-first', the overlap is resolved + // unambiguously by the resolver, so we don't warn + } + } + + // Step 4: Check for alias conflicts + const aliases = registry.getAliases(); + const canonicalNamesSet = new Set(components.map(c => c.canonicalName)); + + for (const [alias, targetCanonical] of aliases) { + // Check if alias shadows a canonical name + // (This can happen if a component was registered AFTER the alias was created) + if (canonicalNamesSet.has(alias)) { + const shadowedComponent = components.find(c => c.canonicalName === alias); + const aliasedComponent = components.find(c => c.canonicalName === targetCanonical); + + diagnostics.push({ + class: 'alias-conflict', + severity: 'warning', + involvedCanonicalNames: [alias, targetCanonical], + filePaths: [ + shadowedComponent?.filePath ?? '', + aliasedComponent?.filePath ?? '', + ], + remediation: formatAliasShadowsCanonicalRemediation(alias, targetCanonical), + alias, + aliasTarget: targetCanonical, + aliasConflictType: 'shadows-canonical', + }); + continue; // Skip further checks for this alias + } + + // Check if alias shadows a bare name (matches component.name in any namespace) + const matchingBareNames = components.filter(c => c.name === alias); + if (matchingBareNames.length > 0) { + const filePaths = matchingBareNames.map(c => c.filePath); + const aliasedComponent = components.find(c => c.canonicalName === targetCanonical); + if (aliasedComponent) filePaths.push(aliasedComponent.filePath); + + diagnostics.push({ + class: 'alias-conflict', + severity: 'warning', + involvedCanonicalNames: [targetCanonical, ...matchingBareNames.map(c => c.canonicalName)], + filePaths, + remediation: formatAliasShadowsBareNameRemediation(alias, targetCanonical, matchingBareNames.map(c => c.canonicalName)), + alias, + aliasTarget: targetCanonical, + aliasConflictType: 'shadows-bare-name', + }); + } + } + + return diagnostics; +} + +/** + * Format remediation advice for shorthand overlap. + * + * @param bareName - The ambiguous bare name + * @param canonicalNames - All canonical names that match + * @returns Human-readable remediation message + */ +function formatShorthandRemediation(bareName: string, canonicalNames: string[]): string { + const suggestions = canonicalNames + .map(cn => `\`${cn}\``) + .join(', '); + + return `Bare name "${bareName}" is ambiguous across ${canonicalNames.length} namespaces. ` + + `Use a canonical name (${suggestions}) to avoid ambiguity.`; +} + +/** + * Format remediation advice for alias shadowing a canonical name. + * + * @param alias - The alias that shadows a canonical name + * @param targetCanonical - The canonical name the alias points to + * @returns Human-readable remediation message + */ +function formatAliasShadowsCanonicalRemediation(alias: string, targetCanonical: string): string { + return `Alias "${alias}" shadows an existing canonical name. ` + + `The alias points to "${targetCanonical}", but resolving "${alias}" will now match the component, not the alias. ` + + `Consider rename or remove the alias to avoid confusion.`; +} + +/** + * Format remediation advice for alias shadowing a bare name. + * + * @param alias - The alias that shadows bare names + * @param targetCanonical - The canonical name the alias points to + * @param shadowedCanonicals - The canonical names whose bare names are shadowed + * @returns Human-readable remediation message + */ +function formatAliasShadowsBareNameRemediation( + alias: string, + targetCanonical: string, + shadowedCanonicals: string[] +): string { + const shadowed = shadowedCanonicals.map(cn => `\`${cn}\``).join(', '); + return `Alias "${alias}" shadows ${shadowedCanonicals.length} component(s) with the same bare name (${shadowed}). ` + + `Resolving "${alias}" will use the alias (pointing to "${targetCanonical}"), not shorthand resolution. ` + + `Use canonical names to be explicit, or rename the alias if this is unintended.`; +} + +/** + * Format diagnostics into a human-readable doctor report. + * + * Each diagnostic is formatted with: + * - Severity icon (❌ error / ⚠️ warning) + * - Description of the issue + * - Involved file paths + * - Remediation advice + * + * @param diagnostics - Array of classified diagnostics + * @returns Doctor report with summary and formatted entries + */ +export function doctorReport(diagnostics: ClassifiedDiagnostic[]): DoctorReport { + const summary = { + total: diagnostics.length, + canonicalConflicts: diagnostics.filter(d => d.class === 'canonical-conflict').length, + shorthandOverlaps: diagnostics.filter(d => d.class === 'shorthand-overlap').length, + aliasConflicts: diagnostics.filter(d => d.class === 'alias-conflict').length, + }; + + const entries = diagnostics.map(diagnostic => formatDiagnosticEntry(diagnostic)); + + return { summary, entries }; +} + +/** + * Format a single diagnostic entry for display. + * + * @param diagnostic - The diagnostic to format + * @returns Formatted string entry + */ +function formatDiagnosticEntry(diagnostic: ClassifiedDiagnostic): string { + const icon = diagnostic.severity === 'error' ? '❌' : '⚠️'; + const lines: string[] = []; + + // Header with severity and class + lines.push(`${icon} ${diagnostic.class.toUpperCase()}`); + + // Description + if (diagnostic.class === 'canonical-conflict') { + lines.push(` Canonical name conflict: ${diagnostic.involvedCanonicalNames[0]}`); + } else if (diagnostic.class === 'alias-conflict') { + if (diagnostic.aliasConflictType === 'shadows-canonical') { + lines.push(` Alias "${diagnostic.alias}" shadows canonical name (points to ${diagnostic.aliasTarget})`); + } else { + lines.push(` Alias "${diagnostic.alias}" shadows bare name (points to ${diagnostic.aliasTarget})`); + } + } else { + lines.push(` Shorthand overlap: "${diagnostic.ambiguousBareName}" matches ${diagnostic.involvedCanonicalNames.length} components`); + } + + // File paths + lines.push(' Files:'); + for (const path of diagnostic.filePaths) { + lines.push(` - ${path}`); + } + + // Remediation + lines.push(` Remediation: ${diagnostic.remediation}`); + + return lines.join('\n'); +} + +// ============================================================================ +// Exports +// ============================================================================ + +export default { + analyzeCollisions, + doctorReport, +}; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 17fb3de2b..653deba1d 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -25,7 +25,8 @@ import { loadEffectiveGSDPreferences, resolveAllSkillReferences, } from "./preferences.js"; -import { loadFile, saveFile, appendOverride, appendKnowledge } from "./files.js"; +import { loadFile, saveFile, appendOverride, appendKnowledge, splitFrontmatter, parseFrontmatterMap } from "./files.js"; +import { runClaudeImportFlow } from "./claude-import.js"; import { formatDoctorIssuesForPrompt, formatDoctorReport, @@ -91,7 +92,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts[0] === "prefs" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["global", "project", "status", "wizard", "setup"] + return ["global", "project", "status", "wizard", "setup", "import-claude"] .filter((cmd) => cmd.startsWith(subPrefix)) .map((cmd) => ({ value: `prefs ${cmd}`, label: cmd })); } @@ -473,6 +474,15 @@ async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise< return; } + if (trimmed === "import-claude" || trimmed === "import-claude global") { + await handleImportClaude(ctx, "global"); + return; + } + + if (trimmed === "import-claude project") { + await handleImportClaude(ctx, "project"); + return; + } if (trimmed === "status") { const globalPrefs = loadGlobalGSDPreferences(); const projectPrefs = loadProjectGSDPreferences(); @@ -503,7 +513,38 @@ async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise< return; } - ctx.ui.notify("Usage: /gsd prefs [global|project|status|wizard|setup]", "info"); + ctx.ui.notify("Usage: /gsd prefs [global|project|status|wizard|setup|import-claude [global|project]]", "info"); +} + +async function handleImportClaude(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise { + const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); + if (!existsSync(path)) { + await ensurePreferencesFile(path, ctx, scope); + } + + const readPrefs = (): Record => { + if (!existsSync(path)) return { version: 1 }; + const content = readFileSync(path, "utf-8"); + const [frontmatterLines] = splitFrontmatter(content); + return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; + }; + + const writePrefs = async (prefs: Record): Promise => { + prefs.version = prefs.version || 1; + const frontmatter = serializePreferencesToFrontmatter(prefs); + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + if (existsSync(path)) { + const existingContent = readFileSync(path, "utf-8"); + const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); + if (closingIdx !== -1) { + const afterFrontmatter = existingContent.slice(closingIdx + 4); + if (afterFrontmatter.trim()) body = afterFrontmatter; + } + } + await saveFile(path, `---\n${frontmatter}---${body}`); + }; + + await runClaudeImportFlow(ctx, scope, readPrefs, writePrefs); } async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { diff --git a/src/resources/extensions/gsd/marketplace-discovery.ts b/src/resources/extensions/gsd/marketplace-discovery.ts new file mode 100644 index 000000000..317b1e7f0 --- /dev/null +++ b/src/resources/extensions/gsd/marketplace-discovery.ts @@ -0,0 +1,502 @@ +/** + * Marketplace Discovery Module + * + * Reads marketplace.json from Claude marketplace repos, resolves plugin source paths, + * parses plugin.json manifests, and inventories available components (skills, agents, commands, MCP servers, LSP servers, hooks). + * + * Handles two marketplace formats: + * 1. jamie-style (../claude_skills): marketplace.json has {name, source} entries; plugins have .claude-plugin/plugin.json + * 2. official-style (../claude-plugins-official): marketplace.json entries contain inline metadata + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** Owner information in marketplace manifest */ +export interface MarketplaceOwner { + name: string; + email?: string; + url?: string; +} + +/** Marketplace metadata */ +export interface MarketplaceMetadata { + description?: string; + version?: string; +} + +/** Source can be a relative path or a complex object (github, url, git-subdir) */ +export type PluginSource = string | { + source?: string; + repo?: string; + url?: string; + path?: string; + sha?: string; + ref?: string; +}; + +/** Marketplace plugin entry - minimal info from marketplace.json */ +export interface MarketplacePluginEntry { + name: string; + source: PluginSource; + // Optional inline metadata (official-style) + description?: string; + version?: string; + author?: MarketplaceOwner; + category?: string; + homepage?: string; + strict?: boolean; + mcpServers?: Record; + lspServers?: Record; + tags?: string[]; +} + +/** Complete marketplace manifest */ +export interface MarketplaceManifest { + $schema?: string; + name: string; + description?: string; + owner?: MarketplaceOwner; + metadata?: MarketplaceMetadata; + plugins: MarketplacePluginEntry[]; +} + +/** Plugin manifest from .claude-plugin/plugin.json */ +export interface PluginManifest { + name: string; + description?: string; + version?: string; + author?: MarketplaceOwner; + homepage?: string; + mcpServers?: Record; + lspServers?: Record; + // Additional fields that might be present + [key: string]: unknown; +} + +/** Inventory of components in a plugin */ +export interface PluginComponentInventory { + skills: string[]; + agents: string[]; + commands: string[]; + mcpServers: Record; + lspServers: Record; + hooks?: string[]; +} + +/** Discovered plugin with all metadata and inventory */ +export interface DiscoveredPlugin { + name: string; + canonicalName: string; + source: PluginSource; + resolvedPath: string | null; + status: 'ok' | 'error'; + error?: string; + // Metadata sources + manifestSource: 'plugin.json' | 'marketplace-inline' | 'derived'; + description?: string; + version?: string; + author?: MarketplaceOwner; + category?: string; + homepage?: string; + // Component inventory + inventory: PluginComponentInventory; +} + +/** Result of marketplace discovery */ +export interface MarketplaceDiscoveryResult { + status: 'ok' | 'error'; + error?: string; + marketplacePath: string; + marketplaceName: string; + pluginFormat: 'jamie-style' | 'official-style' | 'unknown'; + plugins: DiscoveredPlugin[]; + summary: { + total: number; + ok: number; + error: number; + }; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Check if a source path is a relative local path (not a URL or complex source) + */ +function isLocalSource(source: PluginSource): source is string { + if (typeof source === 'string') { + return !source.startsWith('http://') && + !source.startsWith('https://') && + !source.startsWith('git@') && + !source.includes('://'); + } + return false; +} + +/** + * Resolve a relative source path to an absolute directory path + */ +export function resolvePluginRoot(repoRoot: string, source: PluginSource): string | null { + if (!isLocalSource(source)) { + // External source (URL, git repo) - can't resolve locally + return null; + } + + // Handle both ./plugins/name and plugins/name formats + let resolvedPath = source; + if (source.startsWith('./')) { + resolvedPath = source.slice(2); + } + + const absolutePath = path.resolve(repoRoot, resolvedPath); + return absolutePath; +} + +// ============================================================================ +// Core Functions +// ============================================================================ + +/** + * Parse marketplace.json from a marketplace repository root + * + * @param repoRoot - Absolute path to the marketplace repository root + * @returns Parsed marketplace manifest or error + */ +export function parseMarketplaceJson(repoRoot: string): + | { success: true; manifest: MarketplaceManifest } + | { success: false; error: string } { + + const marketplacePath = path.join(repoRoot, '.claude-plugin', 'marketplace.json'); + + // Check if file exists + if (!fs.existsSync(marketplacePath)) { + return { + success: false, + error: `marketplace.json not found at ${marketplacePath}` + }; + } + + // Read and parse JSON + let content: string; + try { + content = fs.readFileSync(marketplacePath, 'utf-8'); + } catch (err) { + return { + success: false, + error: `Failed to read marketplace.json: ${err instanceof Error ? err.message : String(err)}` + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (err) { + return { + success: false, + error: `Failed to parse marketplace.json: ${err instanceof Error ? err.message : String(err)}` + }; + } + + // Validate structure + if (!parsed || typeof parsed !== 'object') { + return { + success: false, + error: 'marketplace.json is not a valid JSON object' + }; + } + + const manifest = parsed as MarketplaceManifest; + + if (!manifest.name) { + return { + success: false, + error: 'marketplace.json missing required field: name' + }; + } + + if (!Array.isArray(manifest.plugins)) { + return { + success: false, + error: 'marketplace.json missing or invalid field: plugins (must be array)' + }; + } + + return { success: true, manifest }; +} + +/** + * Inspect a plugin directory to extract metadata and inventory + * + * @param pluginDir - Absolute path to the plugin directory + * @param marketplaceEntry - Optional marketplace entry for inline metadata fallback + * @returns Discovered plugin information + */ +export function inspectPlugin( + pluginDir: string, + marketplaceEntry?: MarketplacePluginEntry +): DiscoveredPlugin { + const result: DiscoveredPlugin = { + name: marketplaceEntry?.name || path.basename(pluginDir), + canonicalName: marketplaceEntry?.name || path.basename(pluginDir), + source: marketplaceEntry?.source || './', + resolvedPath: pluginDir, + status: 'ok', + manifestSource: 'derived', + inventory: { + skills: [], + agents: [], + commands: [], + mcpServers: {}, + lspServers: {}, + hooks: [] + } + }; + + // Check if directory exists + if (!fs.existsSync(pluginDir)) { + result.status = 'error'; + result.error = `Plugin directory not found: ${pluginDir}`; + return result; + } + + // Try to read plugin.json from .claude-plugin/ + const pluginJsonPath = path.join(pluginDir, '.claude-plugin', 'plugin.json'); + + if (fs.existsSync(pluginJsonPath)) { + try { + const content = fs.readFileSync(pluginJsonPath, 'utf-8'); + const manifest = JSON.parse(content) as PluginManifest; + + // Extract metadata from plugin.json + result.manifestSource = 'plugin.json'; + result.description = manifest.description; + result.version = manifest.version; + result.author = manifest.author; + result.homepage = manifest.homepage; + + if (manifest.mcpServers) { + result.inventory.mcpServers = manifest.mcpServers; + } + if (manifest.lspServers) { + result.inventory.lspServers = manifest.lspServers; + } + } catch (err) { + // Fall back to marketplace inline or derived + result.error = `Failed to parse plugin.json: ${err instanceof Error ? err.message : String(err)}`; + } + } + + // If no plugin.json, use marketplace inline metadata + if (result.manifestSource === 'derived' && marketplaceEntry) { + result.manifestSource = 'marketplace-inline'; + result.description = marketplaceEntry.description; + result.version = marketplaceEntry.version; + result.author = marketplaceEntry.author; + result.category = marketplaceEntry.category; + result.homepage = marketplaceEntry.homepage; + + if (marketplaceEntry.mcpServers) { + result.inventory.mcpServers = marketplaceEntry.mcpServers; + } + if (marketplaceEntry.lspServers) { + result.inventory.lspServers = marketplaceEntry.lspServers; + } + } + + // Try to read plugin.json in root (alternative location) + const altPluginJsonPath = path.join(pluginDir, 'plugin.json'); + if (fs.existsSync(altPluginJsonPath) && result.manifestSource === 'derived') { + try { + const content = fs.readFileSync(altPluginJsonPath, 'utf-8'); + const manifest = JSON.parse(content) as PluginManifest; + + result.manifestSource = 'plugin.json'; + if (!result.description && manifest.description) { + result.description = manifest.description; + } + if (!result.version && manifest.version) { + result.version = manifest.version; + } + if (!result.author && manifest.author) { + result.author = manifest.author; + } + } catch { + // Ignore parse errors for alternative location + } + } + + // Inventory component directories + const skillsDir = path.join(pluginDir, 'skills'); + if (fs.existsSync(skillsDir) && fs.statSync(skillsDir).isDirectory()) { + try { + result.inventory.skills = fs.readdirSync(skillsDir) + .filter(item => { + const itemPath = path.join(skillsDir, item); + return fs.statSync(itemPath).isDirectory() || item.endsWith('.md'); + }); + } catch { + // Ignore read errors + } + } + + const agentsDir = path.join(pluginDir, 'agents'); + if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) { + try { + result.inventory.agents = fs.readdirSync(agentsDir) + .filter(item => { + const itemPath = path.join(agentsDir, item); + return fs.statSync(itemPath).isDirectory() || item.endsWith('.md'); + }); + } catch { + // Ignore read errors + } + } + + const commandsDir = path.join(pluginDir, 'commands'); + if (fs.existsSync(commandsDir) && fs.statSync(commandsDir).isDirectory()) { + try { + result.inventory.commands = fs.readdirSync(commandsDir) + .filter(item => { + const itemPath = path.join(commandsDir, item); + return fs.statSync(itemPath).isDirectory() || item.endsWith('.md'); + }); + } catch { + // Ignore read errors + } + } + + // Also check for hooks at root level (jamie-style uses 'hooks/', not '.claude-plugin/hooks') + const rootHooksDir = path.join(pluginDir, 'hooks'); + if (fs.existsSync(rootHooksDir) && fs.statSync(rootHooksDir).isDirectory()) { + try { + const rootHooks = fs.readdirSync(rootHooksDir) + .filter(item => { + const itemPath = path.join(rootHooksDir, item); + return fs.statSync(itemPath).isDirectory() || item.endsWith('.md') || item.endsWith('.json'); + }); + const mergedHooks = [...result.inventory.hooks, ...rootHooks]; + result.inventory.hooks = Array.from(new Set(mergedHooks)); + } catch { + // Ignore read errors + } + } + + // Also check .claude-plugin/hooks (official-style) + const hooksDir = path.join(pluginDir, '.claude-plugin', 'hooks'); + if (fs.existsSync(hooksDir) && fs.statSync(hooksDir).isDirectory()) { + try { + const pluginHooks = fs.readdirSync(hooksDir) + .filter(item => { + const itemPath = path.join(hooksDir, item); + return fs.statSync(itemPath).isDirectory() || item.endsWith('.md'); + }); + const mergedHooks = [...result.inventory.hooks, ...pluginHooks]; + result.inventory.hooks = Array.from(new Set(mergedHooks)); + } catch { + // Ignore read errors + } + } + + return result; +} + +/** + * Discover all plugins in a marketplace repository + * + * @param repoRoot - Absolute or relative path to the marketplace repository + * @returns Marketplace discovery result with all plugins + */ +export function discoverMarketplace(repoRoot: string): MarketplaceDiscoveryResult { + // Resolve to absolute path + const absoluteRepoRoot = path.resolve(repoRoot); + + // Parse marketplace.json + const parseResult = parseMarketplaceJson(absoluteRepoRoot); + + if (parseResult.success === false) { + return { + status: 'error', + error: parseResult.error, + marketplacePath: path.join(absoluteRepoRoot, '.claude-plugin', 'marketplace.json'), + marketplaceName: path.basename(absoluteRepoRoot), + pluginFormat: 'unknown', + plugins: [], + summary: { total: 0, ok: 0, error: 0 } + }; + } + + const manifest = parseResult.manifest; + + // Determine plugin format based on structure + const pluginFormat: 'jamie-style' | 'official-style' | 'unknown' = + manifest.plugins.every(p => p.source && !p.description && !p.version && !p.lspServers) + ? 'jamie-style' + : manifest.plugins.every(p => p.source && (p.description || p.version || p.lspServers)) + ? 'official-style' + : 'unknown'; + + // Discover each plugin + const plugins: DiscoveredPlugin[] = manifest.plugins.map(entry => { + const resolvedPath = resolvePluginRoot(absoluteRepoRoot, entry.source); + + if (!resolvedPath) { + // External source - can't resolve locally + return { + name: entry.name, + canonicalName: entry.name, + source: entry.source, + resolvedPath: null, + status: 'ok', + manifestSource: 'marketplace-inline', + description: entry.description, + version: entry.version, + author: entry.author, + category: entry.category, + homepage: entry.homepage, + inventory: { + skills: [], + agents: [], + commands: [], + mcpServers: entry.mcpServers || {}, + lspServers: entry.lspServers || {}, + hooks: [] + } + }; + } + + return inspectPlugin(resolvedPath, entry); + }); + + // Calculate summary + const summary = { + total: plugins.length, + ok: plugins.filter(p => p.status === 'ok').length, + error: plugins.filter(p => p.status === 'error').length + }; + + return { + status: summary.error > 0 ? 'error' : 'ok', + marketplacePath: path.join(absoluteRepoRoot, '.claude-plugin', 'marketplace.json'), + marketplaceName: manifest.name, + pluginFormat, + plugins, + summary + }; +} + +// ============================================================================ +// Export all types and functions +// ============================================================================ + +export default { + parseMarketplaceJson, + inspectPlugin, + discoverMarketplace, + resolvePluginRoot +}; diff --git a/src/resources/extensions/gsd/namespaced-registry.ts b/src/resources/extensions/gsd/namespaced-registry.ts new file mode 100644 index 000000000..ec6f05089 --- /dev/null +++ b/src/resources/extensions/gsd/namespaced-registry.ts @@ -0,0 +1,467 @@ +/** + * Namespaced Component Registry Module + * + * Provides the canonical identity model for imported plugin components. + * Supports both namespaced (plugin:component) and flat (bare name) components, + * detects collisions at registration time, and provides lookup by canonical name + * or namespace listing. + * + * This registry serves as the bridge between S01's plugin discovery output + * and Pi's internal component resolution system. + */ + +import type { DiscoveredPlugin } from './marketplace-discovery.js'; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * Component type enumeration. + * Matches the component categories discovered by S01. + */ +export type ComponentType = 'skill' | 'agent'; + +/** + * A component entry in the namespaced registry. + * + * Components can be: + * - Namespaced: `${namespace}:${name}` (e.g., "my-plugin:code-review") + * - Flat: `${name}` (e.g., "code-review" for backward compatibility) + */ +export interface NamespacedComponent { + /** The component's local name (e.g., "code-review") */ + name: string; + + /** The plugin namespace (e.g., "my-plugin"). Undefined for flat components. */ + namespace: string | undefined; + + /** The computed canonical identifier: `${namespace}:${name}` or bare `name` */ + canonicalName: string; + + /** Component type: skill or agent */ + type: ComponentType; + + /** Absolute path to the component's definition file */ + filePath: string; + + /** Source identifier (e.g., "plugin:my-plugin", "user", "project") */ + source: string; + + /** Optional description from the component's frontmatter */ + description: string | undefined; + + /** Extensible metadata bag for plugin origin info */ + metadata: { + /** Plugin version if available */ + pluginVersion?: string; + /** Plugin author if available */ + pluginAuthor?: string; + /** Plugin homepage if available */ + pluginHomepage?: string; + /** Plugin category if available */ + pluginCategory?: string; + /** Original component directory name */ + componentDir?: string; + /** Additional plugin-specific metadata */ + [key: string]: unknown; + }; +} + +/** + * Collision information for registry diagnostics. + * Mirrors the ResourceCollision pattern from pi-coding-agent. + */ +export interface RegistryCollision { + /** The canonical name that collided (e.g., "my-plugin:code-review") */ + canonicalName: string; + + /** Path to the component that won (first registered) */ + winnerPath: string; + + /** Path to the component that lost (subsequent duplicate) */ + loserPath: string; + + /** Source of the winning component */ + winnerSource?: string; + + /** Source of the losing component */ + loserSource?: string; +} + +/** + * Diagnostic entry for registry operations. + * Currently only reports collisions, but extensible for future diagnostics. + */ +export interface RegistryDiagnostic { + /** Diagnostic type */ + type: 'collision'; + + /** Human-readable message */ + message: string; + + /** Collision details */ + collision: RegistryCollision; +} + +/** + * Result of an alias registration attempt. + * Successful registrations return success: true. + * Failed registrations return success: false with a reason. + */ +export interface AliasRegistrationResult { + /** Whether the registration succeeded */ + success: boolean; + + /** On failure, the reason for rejection */ + reason?: 'canonical-not-found' | 'shadows-canonical' | 'duplicate-alias'; + + /** Human-readable message */ + message?: string; +} + +// ============================================================================ +// NamespacedRegistry Class +// ============================================================================ + +/** + * Registry for namespaced plugin components. + * + * Features: + * - Computes canonical names from namespace + name + * - Detects and reports collisions at registration time + * - First registration wins; subsequent duplicates return diagnostic + * - Lookup by canonical name or namespace listing + * - Compatible with both namespaced and flat (non-namespaced) components + * + * Usage: + * ```typescript + * const registry = new NamespacedRegistry(); + * + * // Register a namespaced component + * const diag = registry.register({ + * name: 'code-review', + * namespace: 'my-plugin', + * type: 'skill', + * filePath: '/plugins/my-plugin/skills/code-review/SKILL.md', + * source: 'plugin:my-plugin', + * description: 'Reviews code for quality issues', + * metadata: { pluginVersion: '1.0.0' } + * }); + * + * // Lookup by canonical name + * const skill = registry.getByCanonical('my-plugin:code-review'); + * + * // List all components in a namespace + * const allSkills = registry.getByNamespace('my-plugin'); + * ``` + */ +export class NamespacedRegistry { + /** Internal storage: canonicalName -> component */ + private components = new Map(); + + /** Internal storage: alias -> canonicalName */ + private aliasMap = new Map(); + + /** Collision diagnostics collected during registration */ + private diagnostics: RegistryDiagnostic[] = []; + + /** + * Register a component in the registry. + * + * Computes the canonical name as `${namespace}:${name}` when namespace is present, + * or bare `name` otherwise. Returns a diagnostic if the canonical name already exists. + * + * @param component - Component data (without canonicalName, which is computed) + * @returns Diagnostic if collision detected, undefined otherwise + */ + register(component: Omit): RegistryDiagnostic | undefined { + // Compute canonical name + const canonicalName = component.namespace + ? `${component.namespace}:${component.name}` + : component.name; + + // Create full component with canonical name + const fullComponent: NamespacedComponent = { + ...component, + canonicalName, + }; + + // Check for collision + const existing = this.components.get(canonicalName); + if (existing) { + const diagnostic: RegistryDiagnostic = { + type: 'collision', + message: `canonical name "${canonicalName}" collision`, + collision: { + canonicalName, + winnerPath: existing.filePath, + loserPath: component.filePath, + winnerSource: existing.source, + loserSource: component.source, + }, + }; + this.diagnostics.push(diagnostic); + return diagnostic; + } + + // Register the component + this.components.set(canonicalName, fullComponent); + return undefined; + } + + /** + * Get a component by its canonical name. + * + * @param canonicalName - The canonical name (e.g., "my-plugin:code-review" or "code-review") + * @returns The component if found, undefined otherwise + */ + getByCanonical(canonicalName: string): NamespacedComponent | undefined { + return this.components.get(canonicalName); + } + + /** + * Get all components belonging to a specific namespace. + * + * @param namespace - The namespace to filter by (e.g., "my-plugin") + * @returns Array of components in that namespace + */ + getByNamespace(namespace: string): NamespacedComponent[] { + const results: NamespacedComponent[] = []; + for (const component of this.components.values()) { + if (component.namespace === namespace) { + results.push(component); + } + } + return results; + } + + /** + * Get all registered components. + * + * @returns Array of all components + */ + getAll(): NamespacedComponent[] { + return Array.from(this.components.values()); + } + + /** + * Get all diagnostics collected during registration. + * + * Returns deep copies to prevent external mutation of internal state. + * + * @returns Array of diagnostics (collisions, etc.) + */ + getDiagnostics(): RegistryDiagnostic[] { + return this.diagnostics.map((d) => ({ + type: d.type, + message: d.message, + collision: { ...d.collision }, + })); + } + + /** + * Check if a canonical name is already registered. + * + * @param canonicalName - The canonical name to check + * @returns true if registered, false otherwise + */ + has(canonicalName: string): boolean { + return this.components.has(canonicalName); + } + + /** + * Get the count of registered components. + * + * @returns Number of components + */ + get size(): number { + return this.components.size; + } + + // ============================================================================ + // Alias Management + // ============================================================================ + + /** + * Register an alias for a canonical name. + * + * Validates: + * 1. The target canonical name must exist + * 2. The alias cannot shadow an existing canonical name + * 3. The alias cannot already exist pointing to a different target + * + * @param alias - The short alias (e.g., "py3d") + * @param canonicalName - The target canonical name (e.g., "python-tools:3d-visualizer") + * @returns Result indicating success or failure with reason + */ + registerAlias(alias: string, canonicalName: string): AliasRegistrationResult { + // Check that target canonical name exists + if (!this.components.has(canonicalName)) { + return { + success: false, + reason: 'canonical-not-found', + message: `Cannot create alias "${alias}": target canonical name "${canonicalName}" does not exist`, + }; + } + + // Check that alias doesn't shadow an existing canonical name + if (this.components.has(alias)) { + return { + success: false, + reason: 'shadows-canonical', + message: `Cannot create alias "${alias}": it shadows an existing canonical name`, + }; + } + + // Check for duplicate alias pointing to different target + const existingTarget = this.aliasMap.get(alias); + if (existingTarget !== undefined && existingTarget !== canonicalName) { + return { + success: false, + reason: 'duplicate-alias', + message: `Cannot create alias "${alias}": already exists pointing to "${existingTarget}"`, + }; + } + + // Register the alias (idempotent if same target) + this.aliasMap.set(alias, canonicalName); + + return { success: true }; + } + + /** + * Remove an alias. + * + * @param alias - The alias to remove + * @returns true if the alias existed and was removed, false otherwise + */ + removeAlias(alias: string): boolean { + return this.aliasMap.delete(alias); + } + + /** + * Resolve an alias to its canonical name. + * + * @param alias - The alias to resolve + * @returns The canonical name if alias exists, undefined otherwise + */ + resolveAlias(alias: string): string | undefined { + return this.aliasMap.get(alias); + } + + /** + * Get all registered aliases. + * + * @returns A copy of the alias map (alias -> canonicalName) + */ + getAliases(): Map { + return new Map(this.aliasMap); + } + + /** + * Check if an alias exists. + * + * @param alias - The alias to check + * @returns true if the alias exists, false otherwise + */ + hasAlias(alias: string): boolean { + return this.aliasMap.has(alias); + } +} + +// ============================================================================ +// Discovery Bridge Helper +// ============================================================================ + +/** + * Convert a discovered plugin's inventory into registerable component entries. + * + * This helper bridges S01's discovery output (DiscoveredPlugin) with the + * namespaced registry. It maps skill and agent directory names to component + * entries with the plugin's namespace. + * + * @param plugin - A discovered plugin from S01's discovery process + * @returns Array of registerable component entries (without canonicalName) + */ +export function componentsFromDiscovery( + plugin: DiscoveredPlugin +): Omit[] { + const components: Omit[] = []; + + // Use the plugin's canonical name as the namespace + const namespace = plugin.canonicalName; + + // Extract common metadata from the plugin + const commonMetadata: NamespacedComponent['metadata'] = { + pluginVersion: plugin.version, + pluginAuthor: plugin.author?.name, + pluginHomepage: plugin.homepage, + pluginCategory: plugin.category, + }; + + // Process skills + for (const skillName of plugin.inventory.skills) { + // Resolve the skill file path + // Skills are in /skills//SKILL.md or /skills/.md + let filePath: string; + if (plugin.resolvedPath) { + const skillDirPath = `${plugin.resolvedPath}/skills/${skillName}`; + // Prefer direct markdown file entries, otherwise directory with SKILL.md + filePath = skillName.endsWith('.md') + ? `${plugin.resolvedPath}/skills/${skillName}` + : `${skillDirPath}/SKILL.md`; + } else { + // External plugin - use placeholder path + filePath = `/${namespace}/skills/${skillName}/SKILL.md`; + } + + components.push({ + name: skillName.replace(/\.md$/, ''), // Strip .md if present + namespace, + type: 'skill', + filePath, + source: `plugin:${namespace}`, + description: undefined, // Would require reading the file + metadata: { + ...commonMetadata, + componentDir: skillName, + }, + }); + } + + // Process agents + for (const agentName of plugin.inventory.agents) { + // Resolve the agent file path + let filePath: string; + if (plugin.resolvedPath) { + const agentDirPath = `${plugin.resolvedPath}/agents/${agentName}`; + filePath = agentName.endsWith('.md') + ? `${plugin.resolvedPath}/agents/${agentName}` + : `${agentDirPath}/AGENT.md`; + } else { + filePath = `/${namespace}/agents/${agentName}/AGENT.md`; + } + + components.push({ + name: agentName.replace(/\.md$/, ''), // Strip .md if present + namespace, + type: 'agent', + filePath, + source: `plugin:${namespace}`, + description: undefined, // Would require reading the file + metadata: { + ...commonMetadata, + componentDir: agentName, + }, + }); + } + + return components; +} + +// ============================================================================ +// Exports +// ============================================================================ + +export default NamespacedRegistry; diff --git a/src/resources/extensions/gsd/namespaced-resolver.ts b/src/resources/extensions/gsd/namespaced-resolver.ts new file mode 100644 index 000000000..5c97323f2 --- /dev/null +++ b/src/resources/extensions/gsd/namespaced-resolver.ts @@ -0,0 +1,307 @@ +/** + * Namespaced Resolver Module + * + * Implements context-aware resolution with three-tier lookup precedence: + * 1. Canonical (fully-qualified names with `:`) + * 2. Local-first (caller namespace + bare name) + * 3. Shorthand (bare name matched across all namespaces) + * + * This is the core logic for D003 (same-plugin local-first) and R007/R008 (safe shorthand). + */ + +import type { NamespacedRegistry, NamespacedComponent, ComponentType } from './namespaced-registry.js'; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * Resolution context provided by the caller. + * Used to enable local-first resolution within a namespace. + */ +export interface ResolutionContext { + /** The namespace of the calling component (e.g., "farm" from "farm:caller") */ + callerNamespace?: string; +} + +/** + * Base structure for all resolution results. + */ +interface ResolutionResultBase { + /** The original name passed to resolve() */ + requestedName: string; + + /** How the resolution was performed */ + resolution: 'canonical' | 'alias' | 'local-first' | 'shorthand' | 'ambiguous' | 'not-found'; +} + +/** + * Result when a canonical (fully-qualified) name matches exactly. + * Example: "farm:call-horse" resolves directly to the component with that canonical name. + */ +export interface CanonicalResolution extends ResolutionResultBase { + resolution: 'canonical'; + /** The matched component */ + component: NamespacedComponent; +} + +/** + * Result when an alias resolves to a canonical name. + * Example: "py3d" resolves via alias to "python-tools:3d-visualizer". + */ +export interface AliasResolution extends ResolutionResultBase { + resolution: 'alias'; + /** The matched component */ + component: NamespacedComponent; + /** The alias that was resolved */ + alias: string; + /** The canonical name the alias points to */ + canonicalName: string; +} + +/** + * Result when a bare name resolves via local-first lookup. + * Example: A caller in namespace "farm" resolving bare "call-horse" matches "farm:call-horse". + */ +export interface LocalFirstResolution extends ResolutionResultBase { + resolution: 'local-first'; + /** The matched component */ + component: NamespacedComponent; + /** The namespace used for local-first resolution */ + matchedNamespace: string; +} + +/** + * Result when a bare name matches exactly one component across all namespaces. + * Example: "feed-chickens" resolves if only "farm:feed-chickens" exists. + */ +export interface ShorthandResolution extends ResolutionResultBase { + resolution: 'shorthand'; + /** The matched component */ + component: NamespacedComponent; +} + +/** + * Result when a bare name matches multiple components across namespaces. + * Returns all candidates for diagnostic consumption without throwing. + * Example: "call-horse" matches both "farm:call-horse" and "zoo:call-horse". + */ +export interface AmbiguousResolution extends ResolutionResultBase { + resolution: 'ambiguous'; + /** All components matching the bare name */ + candidates: NamespacedComponent[]; +} + +/** + * Result when no component matches the requested name. + */ +export interface NotFoundResolution extends ResolutionResultBase { + resolution: 'not-found'; +} + +/** + * Discriminated union of all resolution results. + * The `resolution` field indicates which variant applies. + */ +export type ResolutionResult = + | CanonicalResolution + | AliasResolution + | LocalFirstResolution + | ShorthandResolution + | AmbiguousResolution + | NotFoundResolution; + +// ============================================================================ +// NamespacedResolver Class +// ============================================================================ + +/** + * Resolver for namespaced components with context-aware lookup. + * + * Implements four-tier resolution precedence: + * 1. **Canonical**: If name contains `:`, try exact match → return canonical result + * 2. **Alias**: If name is a registered alias → return alias result + * 3. **Local-first**: If `context.callerNamespace` exists, try `${callerNamespace}:${name}` → return local-first result + * 4. **Shorthand**: Scan all components for bare name match → single match returns shorthand, multiple returns ambiguous + * + * Usage: + * ```typescript + * const registry = new NamespacedRegistry(); + * // ... populate registry ... + * // ... register aliases ... + * + * const resolver = new NamespacedResolver(registry); + * + * // Canonical lookup + * const canon = resolver.resolve('farm:call-horse'); + * // canon.resolution === 'canonical' + * + * // Alias resolution + * const alias = resolver.resolve('py3d'); + * // alias.resolution === 'alias', alias.canonicalName === 'python-tools:3d-visualizer' + * + * // Local-first resolution from caller context + * const local = resolver.resolve('call-horse', { callerNamespace: 'farm' }); + * // local.resolution === 'local-first' + * + * // Unambiguous shorthand + * const short = resolver.resolve('unique-skill'); + * // short.resolution === 'shorthand' + * + * // Ambiguous shorthand + * const amb = resolver.resolve('common-skill'); + * // amb.resolution === 'ambiguous', amb.candidates has all matches + * ``` + */ +export class NamespacedResolver { + /** The registry to resolve against */ + private registry: NamespacedRegistry; + + /** + * Create a new resolver for the given registry. + * + * @param registry - The namespaced registry to resolve against + */ + constructor(registry: NamespacedRegistry) { + this.registry = registry; + } + + /** + * Resolve a component name with context-aware lookup. + * + * Implements four-tier resolution precedence: + * 1. **Canonical**: If name contains `:`, try exact match → return canonical result + * 2. **Alias**: If name is a registered alias → return alias result + * 3. **Local-first**: If `context.callerNamespace` exists, try `${callerNamespace}:${name}` → return local-first result + * 4. **Shorthand**: Scan all components for bare name match → single match returns shorthand, multiple returns ambiguous + * + * @param name - The name to resolve (canonical or bare) + * @param context - Optional resolution context with caller namespace + * @param type - Optional type filter (skill or agent) + * @returns Resolution result indicating how the match was found + */ + resolve( + name: string, + context?: ResolutionContext, + type?: ComponentType + ): ResolutionResult { + // Tier 1: Canonical lookup (name contains `:`) + if (name.includes(':')) { + const component = this.registry.getByCanonical(name); + + if (component && this.matchesType(component, type)) { + return { + requestedName: name, + resolution: 'canonical', + component, + }; + } + + // Canonical name not found + return { + requestedName: name, + resolution: 'not-found', + }; + } + + // Tier 2: Alias lookup (before local-first and shorthand) + const aliasTarget = this.registry.resolveAlias(name); + if (aliasTarget) { + const component = this.registry.getByCanonical(aliasTarget); + if (component && this.matchesType(component, type)) { + return { + requestedName: name, + resolution: 'alias', + component, + alias: name, + canonicalName: aliasTarget, + }; + } + } + + // Tier 3: Local-first resolution (if caller namespace provided) + if (context?.callerNamespace) { + const localCanonical = `${context.callerNamespace}:${name}`; + const component = this.registry.getByCanonical(localCanonical); + + if (component && this.matchesType(component, type)) { + return { + requestedName: name, + resolution: 'local-first', + component, + matchedNamespace: context.callerNamespace, + }; + } + } + + // Tier 4: Shorthand resolution (scan all components) + const candidates = this.findBareNameMatches(name, type); + + if (candidates.length === 0) { + return { + requestedName: name, + resolution: 'not-found', + }; + } + + if (candidates.length === 1) { + return { + requestedName: name, + resolution: 'shorthand', + component: candidates[0], + }; + } + + // Multiple matches - ambiguous + return { + requestedName: name, + resolution: 'ambiguous', + candidates, + }; + } + + /** + * Find all components whose local name (without namespace) matches the given bare name. + * Optionally filters by component type. + * + * @param bareName - The bare name to match + * @param type - Optional type filter + * @returns Array of matching components + */ + private findBareNameMatches( + bareName: string, + type?: ComponentType + ): NamespacedComponent[] { + const all = this.registry.getAll(); + + return all.filter((component) => { + // Match by local name (component.name) + if (component.name !== bareName) { + return false; + } + + // Apply type filter if provided + return this.matchesType(component, type); + }); + } + + /** + * Check if a component matches the optional type filter. + * + * @param component - The component to check + * @param type - Optional type filter + * @returns true if no filter or type matches + */ + private matchesType( + component: NamespacedComponent, + type?: ComponentType + ): boolean { + return type === undefined || component.type === type; + } +} + +// ============================================================================ +// Exports +// ============================================================================ + +export default NamespacedResolver; diff --git a/src/resources/extensions/gsd/plugin-importer.ts b/src/resources/extensions/gsd/plugin-importer.ts new file mode 100644 index 000000000..863a936f1 --- /dev/null +++ b/src/resources/extensions/gsd/plugin-importer.ts @@ -0,0 +1,410 @@ +/** + * PluginImporter Service + * + * Composes S01-S04 modules into a staged discover → select → validate → commit pipeline. + * Each stage is independently testable. The service owns no UI — it produces data structures + * that the command layer (T02) consumes. + * + * Pipeline stages: + * 1. discover(marketplacePaths) - Read marketplace manifests, populate registry + * 2. selectComponents(filter) - Filter to user-chosen components + * 3. validateImport(selected) - Check for collisions, return diagnostics + * 4. getImportManifest(selected) - Produce serializable config structure + * + * This service implements R012 (discover/select/import flow) and R013 (canonical name preservation). + */ + +import { + discoverMarketplace, + type MarketplaceDiscoveryResult, + type DiscoveredPlugin, +} from './marketplace-discovery.js'; +import { + NamespacedRegistry, + componentsFromDiscovery, + type NamespacedComponent, +} from './namespaced-registry.js'; +import { NamespacedResolver } from './namespaced-resolver.js'; +import { + analyzeCollisions, + type ClassifiedDiagnostic, +} from './collision-diagnostics.js'; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * Result of the discovery stage. + * Contains all discovered plugins and the populated registry. + */ +export interface DiscoveryResult { + /** All discovery results from each marketplace path */ + marketplaceResults: MarketplaceDiscoveryResult[]; + + /** All discovered plugins aggregated */ + plugins: DiscoveredPlugin[]; + + /** The populated registry with all components */ + registry: NamespacedRegistry; + + /** Summary counts */ + summary: { + marketplacesProcessed: number; + marketplacesWithErrors: number; + totalPlugins: number; + pluginsWithErrors: number; + totalComponents: number; + }; +} + +/** + * Result of the validation stage. + * Contains diagnostics and a proceed flag. + */ +export interface ValidationResult { + /** All classified diagnostics (errors and warnings) */ + diagnostics: ClassifiedDiagnostic[]; + + /** True if import can proceed (no error-severity diagnostics) */ + canProceed: boolean; + + /** Summary counts */ + summary: { + total: number; + errors: number; + warnings: number; + }; +} + +/** + * A single entry in the import manifest config. + * Represents one component to be imported. + */ +export interface ImportManifestEntry { + /** Canonical name: `namespace:name` or bare `name` */ + canonicalName: string; + + /** Component type: 'skill' or 'agent' */ + type: 'skill' | 'agent'; + + /** Local component name (without namespace) */ + name: string; + + /** Plugin namespace (undefined for flat components) */ + namespace: string | undefined; + + /** Absolute path to the component's definition file */ + filePath: string; + + /** Source identifier (e.g., "plugin:my-plugin") */ + source: string; + + /** Optional description */ + description?: string; + + /** Plugin metadata for provenance */ + metadata: { + pluginVersion?: string; + pluginAuthor?: string; + pluginHomepage?: string; + pluginCategory?: string; + }; +} + +/** + * The complete import manifest structure. + * Serializable to JSON for persistence. + */ +export interface ImportManifest { + /** Schema version for future compatibility */ + schemaVersion: '1.0'; + + /** Timestamp when manifest was generated */ + generatedAt: string; + + /** All entries to be imported */ + entries: ImportManifestEntry[]; + + /** Summary counts */ + summary: { + total: number; + skills: number; + agents: number; + namespaces: string[]; + }; +} + +// ============================================================================ +// PluginImporter Class +// ============================================================================ + +/** + * Service for discovering, selecting, validating, and importing plugin components. + * + * Usage: + * ```typescript + * const importer = new PluginImporter(); + * + * // Stage 1: Discover + * const discovery = importer.discover(['../claude-plugins']); + * + * // Stage 2: Select + * const selected = importer.selectComponents(c => c.namespace === 'my-plugin'); + * + * // Stage 3: Validate + * const validation = importer.validateImport(selected); + * if (!validation.canProceed) { + * console.error('Cannot import:', validation.diagnostics); + * return; + * } + * + * // Stage 4: Get manifest for persistence + * const manifest = importer.getImportManifest(selected); + * ``` + */ +export class PluginImporter { + /** The internal registry populated during discovery */ + private registry: NamespacedRegistry | null = null; + + /** All discovered plugins from the last discovery run */ + private discoveredPlugins: DiscoveredPlugin[] = []; + + /** Last discovery result for inspection */ + private lastDiscoveryResult: DiscoveryResult | null = null; + + /** Last validation result for inspection */ + private lastValidationResult: ValidationResult | null = null; + + /** + * Stage 1: Discover plugins from marketplace paths. + * + * Calls `discoverMarketplace()` for each path and populates a `NamespacedRegistry` + * via `componentsFromDiscovery()`. + * + * @param marketplacePaths - Array of paths to marketplace directories + * @returns Discovery result with registry and summary + */ + discover(marketplacePaths: string[]): DiscoveryResult { + // Reset state for fresh discovery + this.registry = new NamespacedRegistry(); + this.discoveredPlugins = []; + this.lastValidationResult = null; + + const marketplaceResults: MarketplaceDiscoveryResult[] = []; + let marketplacesWithErrors = 0; + let pluginsWithErrors = 0; + + // Process each marketplace path + for (const marketplacePath of marketplacePaths) { + const result = discoverMarketplace(marketplacePath); + marketplaceResults.push(result); + + if (result.status === 'error') { + marketplacesWithErrors++; + } + + // Collect all plugins + for (const plugin of result.plugins) { + this.discoveredPlugins.push(plugin); + + if (plugin.status === 'error') { + pluginsWithErrors++; + } + + // Convert plugin inventory to components and register + const components = componentsFromDiscovery(plugin); + for (const component of components) { + this.registry!.register(component); + } + } + } + + // Build summary + const summary = { + marketplacesProcessed: marketplacePaths.length, + marketplacesWithErrors, + totalPlugins: this.discoveredPlugins.length, + pluginsWithErrors, + totalComponents: this.registry.size, + }; + + this.lastDiscoveryResult = { + marketplaceResults, + plugins: this.discoveredPlugins, + registry: this.registry, + summary, + }; + + return this.lastDiscoveryResult; + } + + /** + * Stage 2: Select components by filter function. + * + * Returns a filtered subset of registered components. + * Must be called after discover(). + * + * @param componentFilter - Filter function returning true for selected components + * @returns Array of selected components + */ + selectComponents( + componentFilter: (component: NamespacedComponent) => boolean + ): NamespacedComponent[] { + if (!this.registry) { + throw new Error('Must call discover() before selectComponents()'); + } + + return this.registry.getAll().filter(componentFilter); + } + + /** + * Stage 3: Validate selected components for import. + * + * Builds a `NamespacedResolver`, runs `analyzeCollisions()`, and returns + * `{ diagnostics, canProceed }` where `canProceed` is false if any + * error-severity diagnostics exist. + * + * @param selected - Array of components to validate + * @returns Validation result with diagnostics and proceed flag + */ + validateImport(selected: NamespacedComponent[]): ValidationResult { + if (!this.registry) { + throw new Error('Must call discover() before validateImport()'); + } + + // Create a temporary resolver for the selected components + const tempRegistry = new NamespacedRegistry(); + + // Register only selected components into temp registry + for (const component of selected) { + tempRegistry.register({ + name: component.name, + namespace: component.namespace, + type: component.type, + filePath: component.filePath, + source: component.source, + description: component.description, + metadata: component.metadata, + }); + } + + // Create resolver and analyze collisions + const resolver = new NamespacedResolver(tempRegistry); + const diagnostics = analyzeCollisions(tempRegistry, resolver); + + // Count by severity + const errors = diagnostics.filter((d) => d.severity === 'error').length; + const warnings = diagnostics.filter((d) => d.severity === 'warning').length; + + const summary = { + total: diagnostics.length, + errors, + warnings, + }; + + // canProceed is false if any error-severity diagnostics exist + const canProceed = errors === 0; + + this.lastValidationResult = { + diagnostics, + canProceed, + summary, + }; + + return this.lastValidationResult; + } + + /** + * Stage 4: Generate import manifest for selected components. + * + * Produces a serializable config structure with canonical names preserved. + * The manifest can be persisted to config files. + * + * @param selected - Array of components to include in manifest + * @returns Import manifest with all entries and metadata + */ + getImportManifest(selected: NamespacedComponent[]): ImportManifest { + const entries: ImportManifestEntry[] = selected.map((component) => ({ + canonicalName: component.canonicalName, + type: component.type, + name: component.name, + namespace: component.namespace, + filePath: component.filePath, + source: component.source, + description: component.description, + metadata: { + pluginVersion: component.metadata.pluginVersion, + pluginAuthor: component.metadata.pluginAuthor, + pluginHomepage: component.metadata.pluginHomepage, + pluginCategory: component.metadata.pluginCategory, + }, + })); + + // Count by type + const skills = entries.filter((e) => e.type === 'skill').length; + const agents = entries.filter((e) => e.type === 'agent').length; + + // Collect unique namespaces + const namespaces = Array.from( + new Set(entries.map((e) => e.namespace).filter((n): n is string => n !== undefined)) + ).sort(); + + return { + schemaVersion: '1.0', + generatedAt: new Date().toISOString(), + entries, + summary: { + total: entries.length, + skills, + agents, + namespaces, + }, + }; + } + + /** + * Get the internal registry for inspection. + * Useful for debugging or advanced filtering. + * + * @returns The registry or null if discover() hasn't been called + */ + getRegistry(): NamespacedRegistry | null { + return this.registry; + } + + /** + * Get all discovered plugins. + * + * @returns Array of discovered plugins + */ + getDiscoveredPlugins(): DiscoveredPlugin[] { + return this.discoveredPlugins; + } + + /** + * Get the last validation result. + * Useful for re-inspecting validation without re-running. + * + * @returns Last validation result or null + */ + getLastValidation(): ValidationResult | null { + return this.lastValidationResult; + } + + /** + * Get the last discovery result. + * Useful for re-inspecting discovery without re-running. + * + * @returns Last discovery result or null + */ + getLastDiscovery(): DiscoveryResult | null { + return this.lastDiscoveryResult; + } +} + +// ============================================================================ +// Exports +// ============================================================================ + +export default PluginImporter; diff --git a/src/resources/extensions/gsd/tests/claude-import-tui.test.ts b/src/resources/extensions/gsd/tests/claude-import-tui.test.ts new file mode 100644 index 000000000..fc9ae3db8 --- /dev/null +++ b/src/resources/extensions/gsd/tests/claude-import-tui.test.ts @@ -0,0 +1,314 @@ +/** + * TUI Command Flow Tests for import-claude + * + * Tests R015: Validates the TUI command flow for /gsd prefs import-claude + * Uses mock UI to simulate user selections. + */ + +import { describe, it, before, after, mock } from 'node:test'; +import assert from 'node:assert'; +import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runClaudeImportFlow, getClaudeSearchRoots, discoverClaudeSkills, discoverClaudePlugins } from '../claude-import.js'; +import { getMarketplaceFixtures } from './marketplace-test-fixtures.js'; + +// ============================================================================ +// Test Configuration +// ============================================================================ + +const fixtureSetup = getMarketplaceFixtures(import.meta.dirname); +const fixtures = fixtureSetup.fixtures; +const CLAUDE_SKILLS_PATH = fixtures?.claudeSkillsPath; +const CLAUDE_PLUGINS_OFFICIAL_PATH = fixtures?.claudePluginsOfficialPath; + +function marketplacesAvailable(): boolean { + return Boolean(fixtures); +} + +// ============================================================================ +// Mock UI Context +// ============================================================================ + +interface MockUISelectCall { + prompt: string; + options: string[]; +} + +function createMockContext(selections: string[]): { + ctx: { + ui: { + select: ReturnType; + notify: ReturnType; + }; + waitForIdle: ReturnType; + reload: ReturnType; + }; + selectCalls: MockUISelectCall[]; +} { + const selectCalls: MockUISelectCall[] = []; + + const selectMock = mock.fn(async (prompt: string, options: string[]) => { + selectCalls.push({ prompt, options }); + const next = selections.shift(); + if (next && options.includes(next)) { + return next; + } + // Default: cancel or first option + return options.find(o => o.toLowerCase().includes('cancel')) || options[0]; + }); + + const notifyMock = mock.fn(); + + const ctx = { + ui: { + select: selectMock, + notify: notifyMock, + }, + waitForIdle: mock.fn(async () => {}), + reload: mock.fn(async () => {}), + }; + + return { ctx: ctx as unknown as Parameters[0], selectCalls }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +const skipReason = !marketplacesAvailable() + ? fixtureSetup.skipReason ?? 'Marketplace repos not found for TUI testing' + : undefined; + +describe( + 'TUI Command Flow Tests', + { skip: skipReason }, + () => { + let tempDir: string; + let prefsPath: string; + let prefs: Record; + + before(() => { + tempDir = mkdtempSync(join(tmpdir(), 'gsd-tui-test-')); + prefsPath = join(tempDir, 'preferences.md'); + prefs = { version: 1 }; + }); + + after(() => { + fixtures?.cleanup(); + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('getClaudeSearchRoots()', () => { + it('should return existing skill and plugin roots', () => { + const cwd = process.cwd(); + const { skillRoots, pluginRoots } = getClaudeSearchRoots(cwd); + + // At least one root should exist in our test environment + assert.ok( + skillRoots.length > 0 || pluginRoots.length > 0, + 'Should find at least one search root' + ); + + // All returned roots should exist + for (const root of [...skillRoots, ...pluginRoots]) { + assert.ok(existsSync(root), `Root should exist: ${root}`); + } + }); + }); + + describe('discoverClaudeSkills()', () => { + it('should discover skills without crashing', () => { + const cwd = process.cwd(); + const skills = discoverClaudeSkills(cwd); + + assert.ok(Array.isArray(skills), 'Should return an array'); + + // Log for observability + console.log(`\nDiscovered ${skills.length} skills`); + + if (skills.length > 0) { + console.log('Sample skills:'); + skills.slice(0, 3).forEach(s => { + console.log(` - ${s.name} (${s.sourceLabel})`); + }); + + // Verify structure + const sample = skills[0]!; + assert.ok(sample.name, 'Skill should have name'); + assert.ok(sample.path, 'Skill should have path'); + assert.ok(sample.root, 'Skill should have root'); + assert.strictEqual(sample.type, 'skill'); + } + }); + }); + + describe('discoverClaudePlugins()', () => { + it('should discover plugins without crashing', () => { + const cwd = process.cwd(); + const plugins = discoverClaudePlugins(cwd); + + assert.ok(Array.isArray(plugins), 'Should return an array'); + + // Log for observability + console.log(`\nDiscovered ${plugins.length} plugins`); + + if (plugins.length > 0) { + console.log('Sample plugins:'); + plugins.slice(0, 3).forEach(p => { + console.log(` - ${p.name} (${p.sourceLabel})`); + }); + + // Verify structure + const sample = plugins[0]!; + assert.ok(sample.name, 'Plugin should have name'); + assert.ok(sample.path, 'Plugin should have path'); + assert.strictEqual(sample.type, 'plugin'); + } + }); + }); + + describe('runClaudeImportFlow()', () => { + it('should not crash when user cancels at first prompt', async () => { + const { ctx, selectCalls } = createMockContext(['Cancel']); + + const readPrefs = () => ({ ...prefs }); + const writePrefs = async (p: Record) => { + Object.assign(prefs, p); + }; + + // Should complete without throwing + await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs); + + // Should have asked about asset type + assert.ok(selectCalls.length >= 1, 'Should have at least one select call'); + assert.ok( + selectCalls[0]!.prompt.includes('Import Claude assets'), + 'First prompt should be about asset selection' + ); + }); + + it('should not crash when selecting skills only with cancel at next step', async () => { + const { ctx, selectCalls } = createMockContext([ + 'Skills only', // Select skills only + 'Cancel', // Cancel at skill selection + ]); + + const readPrefs = () => ({ ...prefs }); + const writePrefs = async (p: Record) => { + Object.assign(prefs, p); + }; + + // Should complete without throwing + await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs); + + // Log interaction flow + console.log('\nSelect calls made:'); + selectCalls.forEach((call, i) => { + console.log(` ${i + 1}. "${call.prompt}"`); + }); + }); + + it('should handle marketplace flow when user selects plugins', async () => { + const { ctx, selectCalls } = createMockContext([ + 'Plugins only', // Select plugins only + 'Yes - discover plugins and select components', // Marketplace prompt + 'Cancel', // Cancel at component selection + ]); + + const readPrefs = () => ({ ...prefs }); + const writePrefs = async (p: Record) => { + Object.assign(prefs, p); + }; + + // Should complete without throwing + await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs); + + // Log interaction flow + console.log('\nMarketplace flow select calls:'); + selectCalls.forEach((call, i) => { + console.log(` ${i + 1}. "${call.prompt}"`); + }); + }); + + it('should complete import-all flow with mock UI', async () => { + // This tests the happy path where user selects "Import all" + const { ctx, selectCalls } = createMockContext([ + 'Skills + plugins', // Select both + 'Cancel', // Cancel at skill selection (no skills to import) + 'Yes - discover plugins and select components', // Marketplace prompt + 'Import all components', // Import all + 'Yes, continue', // Continue with warnings (if any) + ]); + + const readPrefs = () => ({ ...prefs }); + const writePrefs = async (p: Record) => { + Object.assign(prefs, p); + }; + + // Should complete without throwing + await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs); + + // Log interaction flow + console.log('\nImport-all flow select calls:'); + selectCalls.forEach((call, i) => { + console.log(` ${i + 1}. "${call.prompt}"`); + }); + + // Verify notification was called + const notifyCalls = (ctx.ui.notify as ReturnType).mock.calls; + assert.ok(notifyCalls.length > 0, 'Should have shown notification'); + + console.log('\nNotifications shown:'); + notifyCalls.forEach((call, i) => { + const msg = call.arguments[0]; + const level = call.arguments[1]; + console.log(` ${i + 1}. [${level}]: ${String(msg).split('\n')[0]}`); + }); + }); + + it('should not persist marketplace agent directories into package sources', async () => { + const isolatedAgentDir = join(tempDir, '.gsd', 'agent'); + const settingsPath = join(isolatedAgentDir, 'settings.json'); + rmSync(isolatedAgentDir, { recursive: true, force: true }); + process.env.GSD_CODING_AGENT_DIR = isolatedAgentDir; + + try { + mkdirSync(isolatedAgentDir, { recursive: true }); + const tempSettings: Record = { packages: [] }; + writeFileSync(settingsPath, JSON.stringify(tempSettings, null, 2)); + + const { ctx } = createMockContext([ + 'Plugins only', + 'Yes - discover plugins and select components', + 'Import all components', + 'Yes, continue', + ]); + + const readPrefs = () => ({ ...prefs }); + const writePrefs = async (p: Record) => { + Object.assign(prefs, p); + }; + + await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs); + + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { packages?: unknown[] }; + const packageEntries = Array.isArray(settings.packages) ? settings.packages : []; + const hasAgentsDirPackage = packageEntries.some((entry) => { + const source = typeof entry === 'string' + ? entry + : (entry && typeof entry === 'object' ? (entry as { source?: unknown }).source : undefined); + return typeof source === 'string' && source.endsWith('/agents'); + }); + + assert.strictEqual(hasAgentsDirPackage, false, 'Marketplace agent directories should not be persisted as package sources'); + } finally { + delete process.env.GSD_CODING_AGENT_DIR; + rmSync(isolatedAgentDir, { recursive: true, force: true }); + } + }); + }); + } +); diff --git a/src/resources/extensions/gsd/tests/collision-diagnostics.test.ts b/src/resources/extensions/gsd/tests/collision-diagnostics.test.ts new file mode 100644 index 000000000..e2f3ef6f6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/collision-diagnostics.test.ts @@ -0,0 +1,705 @@ +/** + * Collision Diagnostics Contract Tests + * + * Tests that prove: + * - R010: Collision reporting distinguishes canonical-conflict from shorthand-overlap + * - R011: Doctor provides actionable advice with canonical name suggestions + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { NamespacedRegistry } from '../namespaced-registry.js'; +import { NamespacedResolver } from '../namespaced-resolver.js'; +import { + analyzeCollisions, + doctorReport, + type ClassifiedDiagnostic, + type DoctorReport, +} from '../collision-diagnostics.js'; + +describe('collision-diagnostics', () => { + let registry: NamespacedRegistry; + let resolver: NamespacedResolver; + + beforeEach(() => { + registry = new NamespacedRegistry(); + resolver = new NamespacedResolver(registry); + }); + + describe('analyzeCollisions', () => { + describe('canonical-conflict detection', () => { + it('should detect canonical conflict when same canonical name registered twice', () => { + // First registration wins + registry.register({ + name: 'code-review', + namespace: 'my-plugin', + type: 'skill', + filePath: '/plugins/my-plugin/skills/code-review/SKILL.md', + source: 'plugin:my-plugin', + description: 'Reviews code', + metadata: {}, + }); + + // Second registration with same canonical name loses + registry.register({ + name: 'code-review', + namespace: 'my-plugin', + type: 'skill', + filePath: '/plugins/other/skills/code-review/SKILL.md', + source: 'plugin:other', + description: 'Another code review', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].class, 'canonical-conflict'); + assert.strictEqual(diagnostics[0].severity, 'error'); + assert.strictEqual(diagnostics[0].involvedCanonicalNames[0], 'my-plugin:code-review'); + assert.ok(diagnostics[0].filePaths.includes('/plugins/my-plugin/skills/code-review/SKILL.md')); + assert.ok(diagnostics[0].filePaths.includes('/plugins/other/skills/code-review/SKILL.md')); + }); + + it('should include remediation advice for canonical conflict', () => { + registry.register({ + name: 'test-skill', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/test-skill/SKILL.md', + source: 'plugin:plugin-a', + description: 'Test', + metadata: {}, + }); + registry.register({ + name: 'test-skill', + namespace: 'plugin-a', + type: 'skill', + filePath: '/b/test-skill/SKILL.md', + source: 'plugin:plugin-b', + description: 'Test duplicate', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + assert.ok(diagnostics[0].remediation.includes('Rename one of the conflicting components')); + }); + }); + + describe('shorthand-overlap detection', () => { + it('should detect shorthand overlap when bare name matches multiple namespaces', () => { + // Same bare name in different namespaces + registry.register({ + name: 'common-skill', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/common-skill/SKILL.md', + source: 'plugin:plugin-a', + description: 'A common skill', + metadata: {}, + }); + registry.register({ + name: 'common-skill', + namespace: 'plugin-b', + type: 'skill', + filePath: '/b/common-skill/SKILL.md', + source: 'plugin:plugin-b', + description: 'B common skill', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].class, 'shorthand-overlap'); + assert.strictEqual(diagnostics[0].severity, 'warning'); + assert.strictEqual(diagnostics[0].ambiguousBareName, 'common-skill'); + assert.ok(diagnostics[0].involvedCanonicalNames.includes('plugin-a:common-skill')); + assert.ok(diagnostics[0].involvedCanonicalNames.includes('plugin-b:common-skill')); + }); + + it('should NOT warn when only one component has a given bare name', () => { + registry.register({ + name: 'unique-skill', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/unique-skill/SKILL.md', + source: 'plugin:plugin-a', + description: 'Unique', + metadata: {}, + }); + registry.register({ + name: 'other-skill', + namespace: 'plugin-b', + type: 'skill', + filePath: '/b/other-skill/SKILL.md', + source: 'plugin:plugin-b', + description: 'Other', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + assert.strictEqual(diagnostics.length, 0); + }); + + it('should include canonical name suggestions in remediation for shorthand overlap', () => { + registry.register({ + name: 'ambiguous', + namespace: 'alpha', + type: 'skill', + filePath: '/alpha/ambiguous/SKILL.md', + source: 'plugin:alpha', + description: 'Alpha ambiguous', + metadata: {}, + }); + registry.register({ + name: 'ambiguous', + namespace: 'beta', + type: 'skill', + filePath: '/beta/ambiguous/SKILL.md', + source: 'plugin:beta', + description: 'Beta ambiguous', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + assert.ok(diagnostics[0].remediation.includes('`alpha:ambiguous`')); + assert.ok(diagnostics[0].remediation.includes('`beta:ambiguous`')); + assert.ok(diagnostics[0].remediation.includes('Use a canonical name')); + }); + }); + + describe('clean registry', () => { + it('should return no diagnostics for empty registry', () => { + const diagnostics = analyzeCollisions(registry, resolver); + assert.strictEqual(diagnostics.length, 0); + }); + + it('should return no diagnostics for registry with unique bare names', () => { + registry.register({ + name: 'skill-a', + namespace: 'plugin-x', + type: 'skill', + filePath: '/x/skill-a/SKILL.md', + source: 'plugin:plugin-x', + description: 'Skill A', + metadata: {}, + }); + registry.register({ + name: 'skill-b', + namespace: 'plugin-y', + type: 'skill', + filePath: '/y/skill-b/SKILL.md', + source: 'plugin:plugin-y', + description: 'Skill B', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + assert.strictEqual(diagnostics.length, 0); + }); + }); + + describe('mixed scenarios', () => { + it('should report both canonical conflict and shorthand overlap in mixed scenario', () => { + // Canonical conflict: same canonical name twice + registry.register({ + name: 'duplicate', + namespace: 'shared', + type: 'skill', + filePath: '/first/duplicate/SKILL.md', + source: 'plugin:first', + description: 'First duplicate', + metadata: {}, + }); + registry.register({ + name: 'duplicate', + namespace: 'shared', + type: 'skill', + filePath: '/second/duplicate/SKILL.md', + source: 'plugin:second', + description: 'Second duplicate', + metadata: {}, + }); + + // Shorthand overlap: same bare name in different namespaces + registry.register({ + name: 'overlap', + namespace: 'ns-a', + type: 'skill', + filePath: '/a/overlap/SKILL.md', + source: 'plugin:ns-a', + description: 'A overlap', + metadata: {}, + }); + registry.register({ + name: 'overlap', + namespace: 'ns-b', + type: 'skill', + filePath: '/b/overlap/SKILL.md', + source: 'plugin:ns-b', + description: 'B overlap', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + assert.strictEqual(diagnostics.length, 2); + + const canonicalConflict = diagnostics.find(d => d.class === 'canonical-conflict'); + const shorthandOverlap = diagnostics.find(d => d.class === 'shorthand-overlap'); + + assert.ok(canonicalConflict, 'Should have canonical conflict'); + assert.ok(shorthandOverlap, 'Should have shorthand overlap'); + + assert.strictEqual(canonicalConflict!.severity, 'error'); + assert.strictEqual(shorthandOverlap!.severity, 'warning'); + }); + }); + + describe('alias-conflict detection', () => { + it('should detect alias that shadows an existing canonical name', () => { + // Register component that will be aliased to + registry.register({ + name: 'utility', + namespace: 'core', + type: 'skill', + filePath: '/core/utility/SKILL.md', + source: 'plugin:core', + description: 'Utility skill', + metadata: {}, + }); + + // Register alias for a non-existent canonical name (will succeed) + registry.registerAlias('tools:helper', 'core:utility'); + + // Now register the component that creates the conflict + registry.register({ + name: 'helper', + namespace: 'tools', + type: 'skill', + filePath: '/tools/helper/SKILL.md', + source: 'plugin:tools', + description: 'Helper skill', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + const aliasConflict = diagnostics.find(d => d.class === 'alias-conflict'); + assert.ok(aliasConflict, 'Should detect alias-conflict'); + assert.strictEqual(aliasConflict!.alias, 'tools:helper'); + assert.strictEqual(aliasConflict!.aliasTarget, 'core:utility'); + assert.strictEqual(aliasConflict!.aliasConflictType, 'shadows-canonical'); + assert.strictEqual(aliasConflict!.severity, 'warning'); + }); + + it('should detect alias that shadows a bare component name', () => { + // Register component with bare name "helper" + registry.register({ + name: 'helper', + namespace: 'tools', + type: 'skill', + filePath: '/tools/helper/SKILL.md', + source: 'plugin:tools', + description: 'Helper skill', + metadata: {}, + }); + + // Register another component to alias to + registry.register({ + name: 'utility', + namespace: 'core', + type: 'skill', + filePath: '/core/utility/SKILL.md', + source: 'plugin:core', + description: 'Utility skill', + metadata: {}, + }); + + // Create alias "helper" that shadows the bare name + registry.registerAlias('helper', 'core:utility'); + + const diagnostics = analyzeCollisions(registry, resolver); + + const aliasConflict = diagnostics.find(d => d.class === 'alias-conflict'); + assert.ok(aliasConflict, 'Should detect alias-conflict'); + assert.strictEqual(aliasConflict!.alias, 'helper'); + assert.strictEqual(aliasConflict!.aliasTarget, 'core:utility'); + assert.strictEqual(aliasConflict!.aliasConflictType, 'shadows-bare-name'); + assert.strictEqual(aliasConflict!.severity, 'warning'); + }); + + it('should NOT warn when alias does not conflict', () => { + registry.register({ + name: 'unique-skill', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/unique-skill/SKILL.md', + source: 'plugin:plugin-a', + description: 'Unique skill', + metadata: {}, + }); + + registry.register({ + name: 'other-skill', + namespace: 'plugin-b', + type: 'skill', + filePath: '/b/other-skill/SKILL.md', + source: 'plugin:plugin-b', + description: 'Other skill', + metadata: {}, + }); + + // Create a non-conflicting alias + registry.registerAlias('short', 'plugin-a:unique-skill'); + + const diagnostics = analyzeCollisions(registry, resolver); + + const aliasConflict = diagnostics.find(d => d.class === 'alias-conflict'); + assert.strictEqual(aliasConflict, undefined, 'Should not have alias-conflict for clean alias'); + }); + + it('should include remediation advice for alias shadowing canonical', () => { + // Register the target component first + registry.register({ + name: 'target', + namespace: 'my-plugin', + type: 'skill', + filePath: '/my-plugin/target/SKILL.md', + source: 'plugin:my-plugin', + description: 'Target skill', + metadata: {}, + }); + + // Register alias for a non-existent canonical name (will succeed because it doesn't exist yet) + registry.registerAlias('other:conflicting', 'my-plugin:target'); + + // Now register the component that the alias would shadow + registry.register({ + name: 'conflicting', + namespace: 'other', + type: 'skill', + filePath: '/other/conflicting/SKILL.md', + source: 'plugin:other', + description: 'Conflicting skill', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + + const aliasConflict = diagnostics.find(d => d.class === 'alias-conflict'); + assert.ok(aliasConflict, 'Should have alias conflict'); + assert.ok(aliasConflict!.remediation.includes('shadows an existing canonical name')); + assert.ok(aliasConflict!.remediation.includes('rename or remove the alias')); + }); + + it('should distinguish alias conflicts from shorthand overlap', () => { + // Shorthand overlap scenario + registry.register({ + name: 'common', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/common/SKILL.md', + source: 'plugin:plugin-a', + description: 'Common A', + metadata: {}, + }); + registry.register({ + name: 'common', + namespace: 'plugin-b', + type: 'skill', + filePath: '/b/common/SKILL.md', + source: 'plugin:plugin-b', + description: 'Common B', + metadata: {}, + }); + + // Alias conflict scenario (separate from shorthand) + registry.register({ + name: 'unique', + namespace: 'plugin-c', + type: 'skill', + filePath: '/c/unique/SKILL.md', + source: 'plugin:plugin-c', + description: 'Unique C', + metadata: {}, + }); + registry.registerAlias('unique', 'plugin-c:unique'); + + const diagnostics = analyzeCollisions(registry, resolver); + + const shorthandOverlap = diagnostics.find(d => d.class === 'shorthand-overlap'); + const aliasConflict = diagnostics.find(d => d.class === 'alias-conflict'); + + assert.ok(shorthandOverlap, 'Should have shorthand overlap'); + assert.ok(aliasConflict, 'Should have alias conflict'); + assert.strictEqual(shorthandOverlap!.ambiguousBareName, 'common'); + assert.strictEqual(aliasConflict!.alias, 'unique'); + }); + }); + }); + + describe('doctorReport', () => { + it('should format report with correct summary counts', () => { + // Create scenario with 1 error and 2 warnings + registry.register({ + name: 'conflict', + namespace: 'ns', + type: 'skill', + filePath: '/a/conflict/SKILL.md', + source: 'plugin:a', + description: 'A', + metadata: {}, + }); + registry.register({ + name: 'conflict', + namespace: 'ns', + type: 'skill', + filePath: '/b/conflict/SKILL.md', + source: 'plugin:b', + description: 'B', + metadata: {}, + }); + registry.register({ + name: 'overlap', + namespace: 'x', + type: 'skill', + filePath: '/x/overlap/SKILL.md', + source: 'plugin:x', + description: 'X', + metadata: {}, + }); + registry.register({ + name: 'overlap', + namespace: 'y', + type: 'skill', + filePath: '/y/overlap/SKILL.md', + source: 'plugin:y', + description: 'Y', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.strictEqual(report.summary.total, 2); + assert.strictEqual(report.summary.canonicalConflicts, 1); + assert.strictEqual(report.summary.shorthandOverlaps, 1); + assert.strictEqual(report.entries.length, 2); + }); + + it('should include error icon for canonical conflicts', () => { + registry.register({ + name: 'dup', + namespace: 'ns', + type: 'skill', + filePath: '/a/dup/SKILL.md', + source: 'plugin:a', + description: 'A', + metadata: {}, + }); + registry.register({ + name: 'dup', + namespace: 'ns', + type: 'skill', + filePath: '/b/dup/SKILL.md', + source: 'plugin:b', + description: 'B', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.ok(report.entries[0].includes('❌')); + }); + + it('should include warning icon for shorthand overlaps', () => { + registry.register({ + name: 'overlap', + namespace: 'a', + type: 'skill', + filePath: '/a/overlap/SKILL.md', + source: 'plugin:a', + description: 'A', + metadata: {}, + }); + registry.register({ + name: 'overlap', + namespace: 'b', + type: 'skill', + filePath: '/b/overlap/SKILL.md', + source: 'plugin:b', + description: 'B', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.ok(report.entries[0].includes('⚠️')); + }); + + it('should include file paths in formatted output', () => { + registry.register({ + name: 'overlap', + namespace: 'a', + type: 'skill', + filePath: '/path/a/overlap/SKILL.md', + source: 'plugin:a', + description: 'A', + metadata: {}, + }); + registry.register({ + name: 'overlap', + namespace: 'b', + type: 'skill', + filePath: '/path/b/overlap/SKILL.md', + source: 'plugin:b', + description: 'B', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.ok(report.entries[0].includes('/path/a/overlap/SKILL.md')); + assert.ok(report.entries[0].includes('/path/b/overlap/SKILL.md')); + }); + + it('should include canonical name suggestions for ambiguous shorthand', () => { + registry.register({ + name: 'common', + namespace: 'plugin-1', + type: 'skill', + filePath: '/1/common/SKILL.md', + source: 'plugin:plugin-1', + description: 'Common 1', + metadata: {}, + }); + registry.register({ + name: 'common', + namespace: 'plugin-2', + type: 'skill', + filePath: '/2/common/SKILL.md', + source: 'plugin:plugin-2', + description: 'Common 2', + metadata: {}, + }); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.ok(report.entries[0].includes('`plugin-1:common`')); + assert.ok(report.entries[0].includes('`plugin-2:common`')); + }); + + it('should return empty arrays for clean registry', () => { + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.strictEqual(report.summary.total, 0); + assert.strictEqual(report.summary.canonicalConflicts, 0); + assert.strictEqual(report.summary.shorthandOverlaps, 0); + assert.strictEqual(report.summary.aliasConflicts, 0); + assert.deepStrictEqual(report.entries, []); + }); + + it('should include alias conflicts in summary counts', () => { + registry.register({ + name: 'target', + namespace: 'my-plugin', + type: 'skill', + filePath: '/my-plugin/target/SKILL.md', + source: 'plugin:my-plugin', + description: 'Target skill', + metadata: {}, + }); + + registry.register({ + name: 'helper', + namespace: 'other', + type: 'skill', + filePath: '/other/helper/SKILL.md', + source: 'plugin:other', + description: 'Helper skill', + metadata: {}, + }); + + // Create alias that shadows bare name + registry.registerAlias('helper', 'my-plugin:target'); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.strictEqual(report.summary.aliasConflicts, 1); + assert.strictEqual(report.summary.total, 1); + }); + + it('should include warning icon for alias conflicts', () => { + registry.register({ + name: 'target', + namespace: 'my-plugin', + type: 'skill', + filePath: '/my-plugin/target/SKILL.md', + source: 'plugin:my-plugin', + description: 'Target skill', + metadata: {}, + }); + + registry.register({ + name: 'shadowed', + namespace: 'other', + type: 'skill', + filePath: '/other/shadowed/SKILL.md', + source: 'plugin:other', + description: 'Shadowed skill', + metadata: {}, + }); + + // Create alias that shadows bare name + registry.registerAlias('shadowed', 'my-plugin:target'); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.ok(report.entries[0].includes('⚠️')); + assert.ok(report.entries[0].includes('ALIAS-CONFLICT')); + }); + + it('should include alias details in formatted output', () => { + registry.register({ + name: 'target', + namespace: 'my-plugin', + type: 'skill', + filePath: '/my-plugin/target/SKILL.md', + source: 'plugin:my-plugin', + description: 'Target skill', + metadata: {}, + }); + + registry.register({ + name: 'shadowed', + namespace: 'other', + type: 'skill', + filePath: '/other/shadowed/SKILL.md', + source: 'plugin:other', + description: 'Shadowed skill', + metadata: {}, + }); + + // Create alias that shadows bare name + registry.registerAlias('shadowed', 'my-plugin:target'); + + const diagnostics = analyzeCollisions(registry, resolver); + const report = doctorReport(diagnostics); + + assert.ok(report.entries[0].includes('shadowed')); + assert.ok(report.entries[0].includes('my-plugin:target')); + }); + }); +}); diff --git a/src/resources/extensions/gsd/tests/marketplace-discovery.test.ts b/src/resources/extensions/gsd/tests/marketplace-discovery.test.ts new file mode 100644 index 000000000..0ef950cb4 --- /dev/null +++ b/src/resources/extensions/gsd/tests/marketplace-discovery.test.ts @@ -0,0 +1,197 @@ +/** + * Marketplace Discovery Tests + * + * Tests for the marketplace discovery module that reads marketplace.json + * from real Claude marketplace repos, resolves plugin roots, parses plugin.json + * manifests, and inventories components. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { + parseMarketplaceJson, + inspectPlugin, + discoverMarketplace, + resolvePluginRoot +} from '../marketplace-discovery'; + +// Use absolute paths to the external marketplace repos +// These repos are located at /home/ubuntulinuxqa2/repos/ +const REPOS_BASE = '/home/ubuntulinuxqa2/repos'; +const CLAUDE_SKILLS_PATH = path.join(REPOS_BASE, 'claude_skills'); +const CLAUDE_PLUGINS_OFFICIAL_PATH = path.join(REPOS_BASE, 'claude-plugins-official'); + +describe('parseMarketplaceJson', () => { + it('should parse jamie-style marketplace.json', () => { + const result = parseMarketplaceJson(CLAUDE_SKILLS_PATH); + assert.strictEqual(result.success, true); + if (result.success) { + assert.strictEqual(result.manifest.name, 'jamie-bitflight-skills'); + assert.strictEqual(result.manifest.plugins.length, 26); + } + }); + + it('should parse official-style marketplace.json', () => { + const result = parseMarketplaceJson(CLAUDE_PLUGINS_OFFICIAL_PATH); + assert.strictEqual(result.success, true); + if (result.success) { + assert.strictEqual(result.manifest.name, 'claude-plugins-official'); + assert.ok(result.manifest.plugins.length > 50); + } + }); + + it('should return error for missing marketplace.json', () => { + const result = parseMarketplaceJson('/tmp/nonexistent'); + assert.strictEqual(result.success, false); + if (!result.success) { + assert.ok(result.error.includes('not found')); + } + }); + + it('should return error for malformed JSON', () => { + const tmpDir = '/tmp/test-marketplace-json-' + Date.now(); + fs.mkdirSync(tmpDir + '/.claude-plugin', { recursive: true }); + fs.writeFileSync(tmpDir + '/.claude-plugin/marketplace.json', '{ invalid json'); + + const result = parseMarketplaceJson(tmpDir); + assert.strictEqual(result.success, false); + if (!result.success) { + assert.ok(result.error.includes('Failed to parse')); + } + + fs.rmSync(tmpDir, { recursive: true }); + }); +}); + +describe('resolvePluginRoot', () => { + it('should resolve relative paths correctly', () => { + const result = resolvePluginRoot(CLAUDE_SKILLS_PATH, './plugins/python3-development'); + assert.strictEqual(result, path.join(CLAUDE_SKILLS_PATH, 'plugins/python3-development')); + }); + + it('should handle paths without ./ prefix', () => { + const result = resolvePluginRoot(CLAUDE_SKILLS_PATH, 'plugins/python3-development'); + assert.strictEqual(result, path.join(CLAUDE_SKILLS_PATH, 'plugins/python3-development')); + }); + + it('should return null for external sources', () => { + const result = resolvePluginRoot(CLAUDE_SKILLS_PATH, 'https://github.com/example/plugin'); + assert.strictEqual(result, null); + }); + + it('should return null for git sources', () => { + const result = resolvePluginRoot(CLAUDE_SKILLS_PATH, { source: 'github', repo: 'example/plugin' }); + assert.strictEqual(result, null); + }); +}); + +describe('inspectPlugin', () => { + it('should inspect a plugin with plugin.json', () => { + const pluginDir = path.join(CLAUDE_SKILLS_PATH, 'plugins/python3-development'); + const result = inspectPlugin(pluginDir); + + assert.strictEqual(result.status, 'ok'); + assert.strictEqual(result.manifestSource, 'plugin.json'); + assert.strictEqual(result.name, 'python3-development'); + assert.ok(result.description !== undefined); + assert.ok(result.version !== undefined); + assert.ok(result.inventory.skills.length > 0); + assert.ok(result.inventory.agents.length > 0); + assert.ok(result.inventory.commands.length > 0); + assert.ok(Object.keys(result.inventory.mcpServers).length > 0); + }); + + it('should return error for non-existent plugin directory', () => { + const result = inspectPlugin('/tmp/nonexistent-plugin'); + assert.strictEqual(result.status, 'error'); + assert.ok(result.error.includes('not found')); + }); +}); + +describe('discoverMarketplace', () => { + it('should discover all plugins in jamie-style marketplace', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + + assert.strictEqual(result.status, 'ok'); + assert.strictEqual(result.pluginFormat, 'jamie-style'); + assert.ok(result.plugins.length > 0); + assert.ok(result.plugins.every(p => p.status === 'ok')); + + assert.strictEqual(result.summary.total, result.plugins.length); + assert.strictEqual(result.summary.ok, result.plugins.length); + assert.strictEqual(result.summary.error, 0); + }); + + it('should discover all plugins in official-style marketplace', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + assert.strictEqual(result.status, 'ok'); + assert.strictEqual(result.pluginFormat, 'official-style'); + assert.ok(result.plugins.length > 50); + }); + + it('should return structured error for missing marketplace', () => { + const result = discoverMarketplace('/tmp/nonexistent'); + + assert.strictEqual(result.status, 'error'); + assert.ok(result.error !== undefined); + assert.ok(result.error.includes('not found')); + assert.deepStrictEqual(result.plugins, []); + assert.strictEqual(result.summary.total, 0); + }); + + it('should inventory skills, agents, commands correctly', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + const pythonPlugin = result.plugins.find(p => p.name === 'python3-development'); + + assert.ok(pythonPlugin !== undefined); + if (pythonPlugin) { + assert.ok(pythonPlugin.inventory.skills.length > 30); + assert.ok(pythonPlugin.inventory.agents.length > 10); + assert.ok(pythonPlugin.inventory.commands.length > 0); + } + }); + + it('should discover MCP servers from plugin.json', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + const pythonPlugin = result.plugins.find(p => p.name === 'python3-development'); + + assert.ok(pythonPlugin !== undefined); + if (pythonPlugin) { + assert.ok(Object.keys(pythonPlugin.inventory.mcpServers).includes('cocoindex-code')); + } + }); + + it('should discover LSP servers from marketplace.json', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + const tsPlugin = result.plugins.find(p => p.name === 'typescript-lsp'); + + assert.ok(tsPlugin !== undefined); + if (tsPlugin) { + assert.ok(Object.keys(tsPlugin.inventory.lspServers).includes('typescript')); + } + }); + + it('should detect external plugins correctly', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + const externalPlugin = result.plugins.find(p => p.name === 'atlassian'); + + assert.ok(externalPlugin !== undefined); + if (externalPlugin) { + assert.strictEqual(externalPlugin.resolvedPath, null); + assert.strictEqual(externalPlugin.status, 'ok'); + } + }); +}); + +describe('smoke test', () => { + it('should be able to run discovery from both marketplace repos', () => { + const jamieResult = discoverMarketplace(CLAUDE_SKILLS_PATH); + const officialResult = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + assert.strictEqual(jamieResult.status, 'ok'); + assert.strictEqual(officialResult.status, 'ok'); + }); +}); diff --git a/src/resources/extensions/gsd/tests/marketplace-test-fixtures.ts b/src/resources/extensions/gsd/tests/marketplace-test-fixtures.ts new file mode 100644 index 000000000..167803b78 --- /dev/null +++ b/src/resources/extensions/gsd/tests/marketplace-test-fixtures.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +export interface MarketplaceFixtureSet { + claudeSkillsPath: string; + claudePluginsOfficialPath: string; + source: 'local' | 'cloned'; + cleanup: () => void; +} + +const CLAUDE_SKILLS_REPO = 'https://github.com/Jamie-BitFlight/claude_skills.git'; +const CLAUDE_PLUGINS_OFFICIAL_REPO = 'https://github.com/Jamie-BitFlight/claude-plugins-official.git'; +const CLONE_FIXTURES_ENABLED = process.env.GSD_TEST_CLONE_MARKETPLACES === '1'; + +function canRunGit(): boolean { + const result = spawnSync('git', ['--version'], { stdio: 'ignore' }); + return result.status === 0; +} + +function cloneRepo(repo: string, dest: string): void { + const result = spawnSync('git', ['clone', '--depth', '1', repo, dest], { + stdio: 'pipe', + encoding: 'utf8', + timeout: 120000, + }); + + if (result.status !== 0) { + const stderr = (result.stderr || result.stdout || '').trim(); + throw new Error(`git clone failed for ${repo}: ${stderr}`); + } +} + +export function getMarketplaceFixtures(testFileDir: string): { available: boolean; skipReason?: string; fixtures?: MarketplaceFixtureSet } { + const gsd2Root = resolve(testFileDir, '../../../../..'); + const localClaudeSkillsPath = resolve(gsd2Root, '../claude_skills'); + const localClaudePluginsOfficialPath = resolve(gsd2Root, '../claude-plugins-official'); + + if (existsSync(localClaudeSkillsPath) && existsSync(localClaudePluginsOfficialPath)) { + return { + available: true, + fixtures: { + claudeSkillsPath: localClaudeSkillsPath, + claudePluginsOfficialPath: localClaudePluginsOfficialPath, + source: 'local', + cleanup: () => {}, + }, + }; + } + + if (!CLONE_FIXTURES_ENABLED) { + return { + available: false, + skipReason: 'Marketplace repos absent and clone-based fixtures are disabled (set GSD_TEST_CLONE_MARKETPLACES=1 to enable)', + }; + } + + if (!canRunGit()) { + return { + available: false, + skipReason: 'Marketplace repos absent and git is unavailable for cloning test fixtures', + }; + } + + try { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'gsd-marketplace-fixtures-')); + const clonedClaudeSkillsPath = join(fixtureRoot, 'claude_skills'); + const clonedClaudePluginsOfficialPath = join(fixtureRoot, 'claude-plugins-official'); + + cloneRepo(CLAUDE_SKILLS_REPO, clonedClaudeSkillsPath); + cloneRepo(CLAUDE_PLUGINS_OFFICIAL_REPO, clonedClaudePluginsOfficialPath); + + return { + available: true, + fixtures: { + claudeSkillsPath: clonedClaudeSkillsPath, + claudePluginsOfficialPath: clonedClaudePluginsOfficialPath, + source: 'cloned', + cleanup: () => { + rmSync(fixtureRoot, { recursive: true, force: true }); + }, + }, + }; + } catch (error) { + return { + available: false, + skipReason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/resources/extensions/gsd/tests/namespaced-registry.test.ts b/src/resources/extensions/gsd/tests/namespaced-registry.test.ts new file mode 100644 index 000000000..f73915e1f --- /dev/null +++ b/src/resources/extensions/gsd/tests/namespaced-registry.test.ts @@ -0,0 +1,1027 @@ +/** + * Namespaced Registry Contract Tests + * + * Tests that prove the namespaced registry correctly handles: + * - Canonical identity (R004) + * - Canonical skill lookup (R005) + * - Canonical agent lookup (R006) + * - Flat compatibility + * - Collision detection + * - Namespace listing + * - Integration with S01 discovery types + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { + NamespacedRegistry, + componentsFromDiscovery, +} from '../namespaced-registry.js'; +import type { DiscoveredPlugin } from '../marketplace-discovery.js'; + +describe('NamespacedRegistry', () => { + let registry: NamespacedRegistry; + + beforeEach(() => { + registry = new NamespacedRegistry(); + }); + + describe('canonical registration and lookup', () => { + it('should register a namespaced skill and compute canonical name (R004, R005)', () => { + const diagnostic = registry.register({ + name: 'my-skill', + namespace: 'my-plugin', + type: 'skill', + filePath: '/plugins/my-plugin/skills/my-skill/SKILL.md', + source: 'plugin:my-plugin', + description: 'A test skill', + metadata: { pluginVersion: '1.0.0' }, + }); + + // No collision diagnostic expected + assert.strictEqual(diagnostic, undefined); + + // Verify registration succeeded + assert.strictEqual(registry.size, 1); + assert.strictEqual(registry.has('my-plugin:my-skill'), true); + + // Lookup by canonical name + const component = registry.getByCanonical('my-plugin:my-skill'); + assert.ok(component !== undefined); + + // Verify canonical identity preserved (R004) + assert.strictEqual(component.name, 'my-skill'); + assert.strictEqual(component.namespace, 'my-plugin'); + assert.strictEqual(component.canonicalName, 'my-plugin:my-skill'); + assert.strictEqual(component.type, 'skill'); + assert.strictEqual(component.filePath, '/plugins/my-plugin/skills/my-skill/SKILL.md'); + assert.strictEqual(component.source, 'plugin:my-plugin'); + assert.strictEqual(component.description, 'A test skill'); + assert.strictEqual(component.metadata.pluginVersion, '1.0.0'); + }); + + it('should register a namespaced agent and compute canonical name (R006)', () => { + const diagnostic = registry.register({ + name: 'abby', + namespace: 'farm', + type: 'agent', + filePath: '/plugins/farm/agents/abby/AGENT.md', + source: 'plugin:farm', + description: 'A farm agent', + metadata: { pluginAuthor: 'farm-team' }, + }); + + assert.strictEqual(diagnostic, undefined); + assert.strictEqual(registry.size, 1); + + // Lookup by canonical name (R006) + const agent = registry.getByCanonical('farm:abby'); + assert.ok(agent !== undefined); + + // Verify canonical identity (R004) + assert.strictEqual(agent.name, 'abby'); + assert.strictEqual(agent.namespace, 'farm'); + assert.strictEqual(agent.canonicalName, 'farm:abby'); + assert.strictEqual(agent.type, 'agent'); + }); + + it('should return undefined for non-existent canonical name', () => { + const result = registry.getByCanonical('nonexistent:skill'); + assert.strictEqual(result, undefined); + }); + }); + + describe('flat (non-namespaced) compatibility', () => { + it('should register flat component with bare name as canonical', () => { + const diagnostic = registry.register({ + name: 'code-review', + namespace: undefined, + type: 'skill', + filePath: '/skills/code-review/SKILL.md', + source: 'user', + description: 'A flat skill', + metadata: {}, + }); + + assert.strictEqual(diagnostic, undefined); + + // Lookup by bare name (no namespace prefix) + const skill = registry.getByCanonical('code-review'); + assert.ok(skill !== undefined); + assert.strictEqual(skill.name, 'code-review'); + assert.strictEqual(skill.namespace, undefined); + assert.strictEqual(skill.canonicalName, 'code-review'); + }); + + it('should retrieve flat component by bare name', () => { + registry.register({ + name: 'test-skill', + namespace: undefined, + type: 'skill', + filePath: '/skills/test-skill/SKILL.md', + source: 'project', + description: undefined, + metadata: {}, + }); + + const skill = registry.getByCanonical('test-skill'); + assert.ok(skill !== undefined); + assert.strictEqual(skill.canonicalName, 'test-skill'); + }); + }); + + describe('collision detection', () => { + it('should detect collision on duplicate canonical name and emit diagnostic', () => { + // First registration wins + const first = registry.register({ + name: 'code-review', + namespace: 'my-plugin', + type: 'skill', + filePath: '/plugins/my-plugin/skills/code-review/SKILL.md', + source: 'plugin:my-plugin', + description: 'First skill', + metadata: {}, + }); + assert.strictEqual(first, undefined); + + // Second registration collides + const second = registry.register({ + name: 'code-review', + namespace: 'my-plugin', + type: 'skill', + filePath: '/plugins/other-plugin/skills/code-review/SKILL.md', + source: 'plugin:other-plugin', + description: 'Second skill', + metadata: {}, + }); + + // Should return collision diagnostic + assert.ok(second !== undefined); + assert.strictEqual(second.type, 'collision'); + assert.strictEqual(second.message, 'canonical name "my-plugin:code-review" collision'); + + // Verify collision details + assert.strictEqual(second.collision.canonicalName, 'my-plugin:code-review'); + assert.strictEqual(second.collision.winnerPath, '/plugins/my-plugin/skills/code-review/SKILL.md'); + assert.strictEqual(second.collision.loserPath, '/plugins/other-plugin/skills/code-review/SKILL.md'); + assert.strictEqual(second.collision.winnerSource, 'plugin:my-plugin'); + assert.strictEqual(second.collision.loserSource, 'plugin:other-plugin'); + }); + + it('should preserve first-wins behavior on collision', () => { + // Register first + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/first/SKILL.md', + source: 'first', + description: 'First description', + metadata: { key: 'first-value' }, + }); + + // Attempt duplicate + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/second/SKILL.md', + source: 'second', + description: 'Second description', + metadata: { key: 'second-value' }, + }); + + // First registration wins + const component = registry.getByCanonical('ns:skill'); + assert.ok(component !== undefined); + assert.strictEqual(component.filePath, '/first/SKILL.md'); + assert.strictEqual(component.source, 'first'); + assert.strictEqual(component.description, 'First description'); + assert.strictEqual(component.metadata.key, 'first-value'); + }); + + it('should collect multiple collision diagnostics', () => { + // First registrations + registry.register({ + name: 'skill-a', + namespace: 'plugin-x', + type: 'skill', + filePath: '/x/a.md', + source: 'x', + description: undefined, + metadata: {}, + }); + registry.register({ + name: 'skill-b', + namespace: 'plugin-y', + type: 'skill', + filePath: '/y/b.md', + source: 'y', + description: undefined, + metadata: {}, + }); + + // Collisions + registry.register({ + name: 'skill-a', + namespace: 'plugin-x', + type: 'skill', + filePath: '/z/a.md', + source: 'z', + description: undefined, + metadata: {}, + }); + registry.register({ + name: 'skill-b', + namespace: 'plugin-y', + type: 'skill', + filePath: '/w/b.md', + source: 'w', + description: undefined, + metadata: {}, + }); + + const diagnostics = registry.getDiagnostics(); + assert.strictEqual(diagnostics.length, 2); + assert.strictEqual(diagnostics[0].collision.canonicalName, 'plugin-x:skill-a'); + assert.strictEqual(diagnostics[1].collision.canonicalName, 'plugin-y:skill-b'); + }); + + it('should allow same name in different namespaces', () => { + // Same name, different namespace + registry.register({ + name: 'code-review', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/code-review.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }); + registry.register({ + name: 'code-review', + namespace: 'plugin-b', + type: 'skill', + filePath: '/b/code-review.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }); + + // Both should be registered + assert.strictEqual(registry.size, 2); + + const a = registry.getByCanonical('plugin-a:code-review'); + const b = registry.getByCanonical('plugin-b:code-review'); + + assert.ok(a !== undefined); + assert.ok(b !== undefined); + assert.strictEqual(a.filePath, '/a/code-review.md'); + assert.strictEqual(b.filePath, '/b/code-review.md'); + + // No collisions + assert.strictEqual(registry.getDiagnostics().length, 0); + }); + + it('should allow flat and namespaced components with same local name', () => { + // Flat component + registry.register({ + name: 'code-review', + namespace: undefined, + type: 'skill', + filePath: '/flat/code-review.md', + source: 'user', + description: undefined, + metadata: {}, + }); + + // Namespaced component with same local name + registry.register({ + name: 'code-review', + namespace: 'plugin', + type: 'skill', + filePath: '/plugin/code-review.md', + source: 'plugin:plugin', + description: undefined, + metadata: {}, + }); + + // Both should be accessible + const flat = registry.getByCanonical('code-review'); + const namespaced = registry.getByCanonical('plugin:code-review'); + + assert.ok(flat !== undefined); + assert.ok(namespaced !== undefined); + assert.strictEqual(flat.namespace, undefined); + assert.strictEqual(namespaced.namespace, 'plugin'); + + assert.strictEqual(registry.getDiagnostics().length, 0); + }); + }); + + describe('namespace listing', () => { + it('should list all components in a namespace via getByNamespace', () => { + // Register multiple components in plugin-a + registry.register({ + name: 'skill-1', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/skill-1.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }); + registry.register({ + name: 'skill-2', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/skill-2.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }); + registry.register({ + name: 'agent-1', + namespace: 'plugin-a', + type: 'agent', + filePath: '/a/agent-1.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }); + + // Register component in different namespace + registry.register({ + name: 'skill-3', + namespace: 'plugin-b', + type: 'skill', + filePath: '/b/skill-3.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }); + + const pluginAComponents = registry.getByNamespace('plugin-a'); + assert.strictEqual(pluginAComponents.length, 3); + + const names = pluginAComponents.map((c) => c.name).sort(); + assert.deepStrictEqual(names, ['agent-1', 'skill-1', 'skill-2']); + + // All should have correct namespace + assert.ok(pluginAComponents.every((c) => c.namespace === 'plugin-a')); + }); + + it('should return empty array for non-existent namespace', () => { + const result = registry.getByNamespace('nonexistent'); + assert.deepStrictEqual(result, []); + }); + + it('should not include flat components in namespace listing', () => { + // Flat component + registry.register({ + name: 'flat-skill', + namespace: undefined, + type: 'skill', + filePath: '/flat.md', + source: 'user', + description: undefined, + metadata: {}, + }); + + // Namespaced component + registry.register({ + name: 'ns-skill', + namespace: 'plugin', + type: 'skill', + filePath: '/plugin/ns-skill.md', + source: 'plugin:plugin', + description: undefined, + metadata: {}, + }); + + // Flat components have namespace=undefined, not included + const pluginComponents = registry.getByNamespace('plugin'); + assert.strictEqual(pluginComponents.length, 1); + assert.strictEqual(pluginComponents[0].name, 'ns-skill'); + }); + }); + + describe('mixed coexistence', () => { + it('should allow both namespaced and flat components without interference', () => { + // Flat skill + registry.register({ + name: 'review', + namespace: undefined, + type: 'skill', + filePath: '/skills/review/SKILL.md', + source: 'user', + description: 'User skill', + metadata: {}, + }); + + // Namespaced skill + registry.register({ + name: 'review', + namespace: 'my-plugin', + type: 'skill', + filePath: '/plugins/my-plugin/skills/review/SKILL.md', + source: 'plugin:my-plugin', + description: 'Plugin skill', + metadata: { pluginVersion: '1.0.0' }, + }); + + // Namespaced agent + registry.register({ + name: 'builder', + namespace: 'my-plugin', + type: 'agent', + filePath: '/plugins/my-plugin/agents/builder/AGENT.md', + source: 'plugin:my-plugin', + description: 'Plugin agent', + metadata: {}, + }); + + // Flat agent + registry.register({ + name: 'assistant', + namespace: undefined, + type: 'agent', + filePath: '/agents/assistant/AGENT.md', + source: 'project', + description: 'Project agent', + metadata: {}, + }); + + // Verify total count + assert.strictEqual(registry.size, 4); + + // Flat skill + const flatSkill = registry.getByCanonical('review'); + assert.ok(flatSkill !== undefined); + assert.strictEqual(flatSkill.namespace, undefined); + assert.strictEqual(flatSkill.type, 'skill'); + + // Namespaced skill + const nsSkill = registry.getByCanonical('my-plugin:review'); + assert.ok(nsSkill !== undefined); + assert.strictEqual(nsSkill.namespace, 'my-plugin'); + assert.strictEqual(nsSkill.type, 'skill'); + + // Namespaced agent + const nsAgent = registry.getByCanonical('my-plugin:builder'); + assert.ok(nsAgent !== undefined); + assert.strictEqual(nsAgent.namespace, 'my-plugin'); + assert.strictEqual(nsAgent.type, 'agent'); + + // Flat agent + const flatAgent = registry.getByCanonical('assistant'); + assert.ok(flatAgent !== undefined); + assert.strictEqual(flatAgent.namespace, undefined); + assert.strictEqual(flatAgent.type, 'agent'); + + // Namespace listing + const myPluginComponents = registry.getByNamespace('my-plugin'); + assert.strictEqual(myPluginComponents.length, 2); + + // No collisions + assert.strictEqual(registry.getDiagnostics().length, 0); + }); + }); + + describe('getAll and has', () => { + it('should return all components via getAll', () => { + registry.register({ + name: 'skill-1', + namespace: 'plugin-a', + type: 'skill', + filePath: '/a/s1.md', + source: 'a', + description: undefined, + metadata: {}, + }); + registry.register({ + name: 'skill-2', + namespace: undefined, + type: 'skill', + filePath: '/s2.md', + source: 'user', + description: undefined, + metadata: {}, + }); + + const all = registry.getAll(); + assert.strictEqual(all.length, 2); + + const canonicalNames = all.map((c) => c.canonicalName).sort(); + assert.deepStrictEqual(canonicalNames, ['plugin-a:skill-1', 'skill-2']); + }); + + it('should check existence via has', () => { + registry.register({ + name: 'test', + namespace: 'ns', + type: 'skill', + filePath: '/test.md', + source: 'test', + description: undefined, + metadata: {}, + }); + + assert.strictEqual(registry.has('ns:test'), true); + assert.strictEqual(registry.has('ns:other'), false); + assert.strictEqual(registry.has('test'), false); + }); + }); +}); + +describe('componentsFromDiscovery', () => { + it('should convert DiscoveredPlugin to registerable components', () => { + const mockPlugin: DiscoveredPlugin = { + name: 'test-plugin', + canonicalName: 'test-plugin', + source: './plugins/test-plugin', + resolvedPath: '/plugins/test-plugin', + status: 'ok', + manifestSource: 'plugin.json', + description: 'A test plugin', + version: '1.0.0', + author: { name: 'Test Author' }, + category: 'testing', + homepage: 'https://example.com/test-plugin', + inventory: { + skills: ['skill-a', 'skill-b'], + agents: ['agent-x'], + commands: [], + mcpServers: {}, + lspServers: {}, + hooks: [], + }, + }; + + const components = componentsFromDiscovery(mockPlugin); + + // Should have 3 components (2 skills + 1 agent) + assert.strictEqual(components.length, 3); + + // All should have the plugin's canonical name as namespace + assert.ok(components.every((c) => c.namespace === 'test-plugin')); + + // Verify skills + const skills = components.filter((c) => c.type === 'skill'); + assert.strictEqual(skills.length, 2); + + const skillNames = skills.map((c) => c.name).sort(); + assert.deepStrictEqual(skillNames, ['skill-a', 'skill-b']); + + // Verify agents + const agents = components.filter((c) => c.type === 'agent'); + assert.strictEqual(agents.length, 1); + assert.strictEqual(agents[0].name, 'agent-x'); + + // Verify metadata propagation + assert.strictEqual(skills[0].metadata.pluginVersion, '1.0.0'); + assert.strictEqual(skills[0].metadata.pluginAuthor, 'Test Author'); + assert.strictEqual(skills[0].metadata.pluginHomepage, 'https://example.com/test-plugin'); + assert.strictEqual(skills[0].metadata.pluginCategory, 'testing'); + + // Verify source format + assert.strictEqual(skills[0].source, 'plugin:test-plugin'); + }); + + it('should handle plugin without resolvedPath (external plugin)', () => { + const externalPlugin: DiscoveredPlugin = { + name: 'external-plugin', + canonicalName: 'external-plugin', + source: { source: 'github', repo: 'example/plugin' }, + resolvedPath: null, // External - not locally resolved + status: 'ok', + manifestSource: 'marketplace-inline', + description: 'An external plugin', + inventory: { + skills: ['remote-skill'], + agents: [], + commands: [], + mcpServers: {}, + lspServers: {}, + hooks: [], + }, + }; + + const components = componentsFromDiscovery(externalPlugin); + + assert.strictEqual(components.length, 1); + assert.strictEqual(components[0].name, 'remote-skill'); + assert.strictEqual(components[0].namespace, 'external-plugin'); + assert.ok(components[0].filePath.includes('')); + }); + + it('should produce components that can be registered in NamespacedRegistry', () => { + const mockPlugin: DiscoveredPlugin = { + name: 'integration-plugin', + canonicalName: 'integration-plugin', + source: './plugins/integration', + resolvedPath: '/plugins/integration', + status: 'ok', + manifestSource: 'plugin.json', + inventory: { + skills: ['int-skill'], + agents: ['int-agent'], + commands: [], + mcpServers: {}, + lspServers: {}, + hooks: [], + }, + }; + + const registry = new NamespacedRegistry(); + const components = componentsFromDiscovery(mockPlugin); + + // Register all components + for (const component of components) { + const diag = registry.register(component); + assert.strictEqual(diag, undefined, 'No collision expected'); + } + + // Verify registration + assert.strictEqual(registry.size, 2); + assert.ok(registry.has('integration-plugin:int-skill')); + assert.ok(registry.has('integration-plugin:int-agent')); + + // Lookup and verify + const skill = registry.getByCanonical('integration-plugin:int-skill'); + assert.ok(skill !== undefined); + assert.strictEqual(skill.type, 'skill'); + + const agent = registry.getByCanonical('integration-plugin:int-agent'); + assert.ok(agent !== undefined); + assert.strictEqual(agent.type, 'agent'); + }); + + it('should strip .md extension from skill/agent names if present', () => { + const pluginWithMd: DiscoveredPlugin = { + name: 'md-plugin', + canonicalName: 'md-plugin', + source: './plugins/md', + resolvedPath: '/plugins/md', + status: 'ok', + manifestSource: 'derived', + inventory: { + skills: ['skill.md'], // .md extension in inventory + agents: ['agent.md'], + commands: [], + mcpServers: {}, + lspServers: {}, + hooks: [], + }, + }; + + const components = componentsFromDiscovery(pluginWithMd); + + const skill = components.find((c) => c.type === 'skill'); + const agent = components.find((c) => c.type === 'agent'); + + assert.ok(skill !== undefined); + assert.ok(agent !== undefined); + assert.strictEqual(skill.name, 'skill'); // .md stripped + assert.strictEqual(agent.name, 'agent'); // .md stripped + }); +}); + +describe('diagnostic structure verification', () => { + it('should emit diagnostic with correct RegistryCollision shape', () => { + const registry = new NamespacedRegistry(); + + registry.register({ + name: 'dup', + namespace: 'ns', + type: 'skill', + filePath: '/first/dup.md', + source: 'first-source', + description: undefined, + metadata: {}, + }); + + const diag = registry.register({ + name: 'dup', + namespace: 'ns', + type: 'skill', + filePath: '/second/dup.md', + source: 'second-source', + description: undefined, + metadata: {}, + }); + + assert.ok(diag !== undefined); + + // Verify diagnostic type + assert.strictEqual(diag.type, 'collision'); + + // Verify message format + assert.ok(diag.message.includes('ns:dup')); + assert.ok(diag.message.includes('collision')); + + // Verify collision object structure + assert.strictEqual(diag.collision.canonicalName, 'ns:dup'); + assert.strictEqual(diag.collision.winnerPath, '/first/dup.md'); + assert.strictEqual(diag.collision.loserPath, '/second/dup.md'); + assert.strictEqual(diag.collision.winnerSource, 'first-source'); + assert.strictEqual(diag.collision.loserSource, 'second-source'); + }); + + it('should provide inspectable diagnostics via getDiagnostics', () => { + const registry = new NamespacedRegistry(); + + // Create collision + registry.register({ + name: 'skill', + namespace: 'plugin', + type: 'skill', + filePath: '/a/skill.md', + source: 'a', + description: undefined, + metadata: {}, + }); + registry.register({ + name: 'skill', + namespace: 'plugin', + type: 'skill', + filePath: '/b/skill.md', + source: 'b', + description: undefined, + metadata: {}, + }); + + const diagnostics = registry.getDiagnostics(); + + assert.strictEqual(diagnostics.length, 1); + + // Verify diagnostic is a copy (not mutable reference) + diagnostics[0].message = 'modified'; + const freshDiagnostics = registry.getDiagnostics(); + assert.strictEqual(freshDiagnostics[0].message, 'canonical name "plugin:skill" collision'); + }); +}); + +describe('alias management', () => { + let registry: NamespacedRegistry; + + beforeEach(() => { + registry = new NamespacedRegistry(); + }); + + describe('registerAlias', () => { + it('should register an alias for an existing canonical name', () => { + registry.register({ + name: '3d-visualizer', + namespace: 'python-tools', + type: 'skill', + filePath: '/python-tools/3d-visualizer/SKILL.md', + source: 'plugin:python-tools', + description: '3D visualization', + metadata: {}, + }); + + const result = registry.registerAlias('py3d', 'python-tools:3d-visualizer'); + + assert.strictEqual(result.success, true); + assert.strictEqual(registry.hasAlias('py3d'), true); + assert.strictEqual(registry.resolveAlias('py3d'), 'python-tools:3d-visualizer'); + }); + + it('should reject alias if target canonical name does not exist', () => { + const result = registry.registerAlias('py3d', 'nonexistent:skill'); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.reason, 'canonical-not-found'); + assert.ok(result.message?.includes('does not exist')); + }); + + it('should reject alias that shadows an existing canonical name', () => { + registry.register({ + name: 'existing', + namespace: 'plugin', + type: 'skill', + filePath: '/plugin/existing/SKILL.md', + source: 'plugin:plugin', + description: 'Existing skill', + metadata: {}, + }); + registry.register({ + name: 'other', + namespace: 'plugin', + type: 'skill', + filePath: '/plugin/other/SKILL.md', + source: 'plugin:plugin', + description: 'Other skill', + metadata: {}, + }); + + // Try to create alias that matches an existing canonical name + const result = registry.registerAlias('plugin:existing', 'plugin:other'); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.reason, 'shadows-canonical'); + assert.ok(result.message?.includes('shadows an existing canonical name')); + }); + + it('should reject duplicate alias pointing to different target', () => { + registry.register({ + name: 'skill-a', + namespace: 'plugin', + type: 'skill', + filePath: '/plugin/skill-a/SKILL.md', + source: 'plugin:plugin', + description: 'Skill A', + metadata: {}, + }); + registry.register({ + name: 'skill-b', + namespace: 'plugin', + type: 'skill', + filePath: '/plugin/skill-b/SKILL.md', + source: 'plugin:plugin', + description: 'Skill B', + metadata: {}, + }); + + // First alias succeeds + const first = registry.registerAlias('shortcut', 'plugin:skill-a'); + assert.strictEqual(first.success, true); + + // Second alias with same name but different target fails + const second = registry.registerAlias('shortcut', 'plugin:skill-b'); + assert.strictEqual(second.success, false); + assert.strictEqual(second.reason, 'duplicate-alias'); + assert.ok(second.message?.includes('already exists')); + }); + + it('should be idempotent for same alias and target', () => { + registry.register({ + name: 'skill', + namespace: 'plugin', + type: 'skill', + filePath: '/plugin/skill/SKILL.md', + source: 'plugin:plugin', + description: 'Skill', + metadata: {}, + }); + + // Register alias twice with same target + const first = registry.registerAlias('s', 'plugin:skill'); + assert.strictEqual(first.success, true); + + const second = registry.registerAlias('s', 'plugin:skill'); + assert.strictEqual(second.success, true); + }); + + it('should allow multiple aliases for same canonical name', () => { + registry.register({ + name: 'visualizer', + namespace: 'python-tools', + type: 'skill', + filePath: '/python-tools/visualizer/SKILL.md', + source: 'plugin:python-tools', + description: 'Visualizer', + metadata: {}, + }); + + const r1 = registry.registerAlias('pyviz', 'python-tools:visualizer'); + const r2 = registry.registerAlias('viz', 'python-tools:visualizer'); + const r3 = registry.registerAlias('py3d', 'python-tools:visualizer'); + + assert.strictEqual(r1.success, true); + assert.strictEqual(r2.success, true); + assert.strictEqual(r3.success, true); + + assert.strictEqual(registry.resolveAlias('pyviz'), 'python-tools:visualizer'); + assert.strictEqual(registry.resolveAlias('viz'), 'python-tools:visualizer'); + assert.strictEqual(registry.resolveAlias('py3d'), 'python-tools:visualizer'); + }); + }); + + describe('resolveAlias', () => { + it('should resolve registered alias to canonical name', () => { + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/ns/skill/SKILL.md', + source: 'plugin:ns', + description: 'Skill', + metadata: {}, + }); + registry.registerAlias('s', 'ns:skill'); + + assert.strictEqual(registry.resolveAlias('s'), 'ns:skill'); + }); + + it('should return undefined for non-existent alias', () => { + assert.strictEqual(registry.resolveAlias('nonexistent'), undefined); + }); + }); + + describe('removeAlias', () => { + it('should remove an existing alias', () => { + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/ns/skill/SKILL.md', + source: 'plugin:ns', + description: 'Skill', + metadata: {}, + }); + registry.registerAlias('s', 'ns:skill'); + + assert.strictEqual(registry.hasAlias('s'), true); + + const removed = registry.removeAlias('s'); + assert.strictEqual(removed, true); + assert.strictEqual(registry.hasAlias('s'), false); + assert.strictEqual(registry.resolveAlias('s'), undefined); + }); + + it('should return false for non-existent alias', () => { + const removed = registry.removeAlias('nonexistent'); + assert.strictEqual(removed, false); + }); + }); + + describe('getAliases', () => { + it('should return empty map when no aliases registered', () => { + const aliases = registry.getAliases(); + assert.strictEqual(aliases.size, 0); + }); + + it('should return copy of alias map', () => { + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/ns/skill/SKILL.md', + source: 'plugin:ns', + description: 'Skill', + metadata: {}, + }); + registry.registerAlias('s', 'ns:skill'); + + const aliases = registry.getAliases(); + assert.strictEqual(aliases.size, 1); + assert.strictEqual(aliases.get('s'), 'ns:skill'); + + // Mutating returned map should not affect registry + aliases.set('other', 'ns:other'); + assert.strictEqual(registry.hasAlias('other'), false); + }); + + it('should include all registered aliases', () => { + registry.register({ + name: 'skill-a', + namespace: 'ns', + type: 'skill', + filePath: '/ns/a/SKILL.md', + source: 'plugin:ns', + description: 'A', + metadata: {}, + }); + registry.register({ + name: 'skill-b', + namespace: 'ns', + type: 'skill', + filePath: '/ns/b/SKILL.md', + source: 'plugin:ns', + description: 'B', + metadata: {}, + }); + + registry.registerAlias('sa', 'ns:skill-a'); + registry.registerAlias('sb', 'ns:skill-b'); + + const aliases = registry.getAliases(); + assert.strictEqual(aliases.size, 2); + assert.strictEqual(aliases.get('sa'), 'ns:skill-a'); + assert.strictEqual(aliases.get('sb'), 'ns:skill-b'); + }); + }); + + describe('hasAlias', () => { + it('should return true for registered alias', () => { + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/ns/skill/SKILL.md', + source: 'plugin:ns', + description: 'Skill', + metadata: {}, + }); + registry.registerAlias('s', 'ns:skill'); + + assert.strictEqual(registry.hasAlias('s'), true); + }); + + it('should return false for non-existent alias', () => { + assert.strictEqual(registry.hasAlias('nonexistent'), false); + }); + }); +}); \ No newline at end of file diff --git a/src/resources/extensions/gsd/tests/namespaced-resolver.test.ts b/src/resources/extensions/gsd/tests/namespaced-resolver.test.ts new file mode 100644 index 000000000..c81c6a254 --- /dev/null +++ b/src/resources/extensions/gsd/tests/namespaced-resolver.test.ts @@ -0,0 +1,671 @@ +/** + * Namespaced Resolver Contract Tests + * + * Tests that prove the resolver correctly handles: + * - R007: Canonical skill lookup + * - R008: Canonical agent lookup + * - D003: Same-plugin local-first resolution + * - R009: Shorthand resolution (unambiguous and ambiguous) + * - Flat component compatibility + * - Type filtering (skill vs agent) + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { NamespacedRegistry } from '../namespaced-registry.js'; +import { NamespacedResolver } from '../namespaced-resolver.js'; + +describe('NamespacedResolver', () => { + let registry: NamespacedRegistry; + let resolver: NamespacedResolver; + + beforeEach(() => { + registry = new NamespacedRegistry(); + resolver = new NamespacedResolver(registry); + }); + + describe('canonical lookup (R007, R008)', () => { + it('should resolve canonical skill name with canonical result (R007)', () => { + registry.register({ + name: 'call-horse', + namespace: 'farm', + type: 'skill', + filePath: '/farm/call-horse/SKILL.md', + source: 'plugin:farm', + description: 'Calls a horse', + metadata: {}, + }); + + const result = resolver.resolve('farm:call-horse'); + + assert.strictEqual(result.resolution, 'canonical'); + if (result.resolution !== 'canonical') throw new Error('Type guard'); + + assert.strictEqual(result.requestedName, 'farm:call-horse'); + assert.strictEqual(result.component.canonicalName, 'farm:call-horse'); + assert.strictEqual(result.component.type, 'skill'); + }); + + it('should resolve canonical agent name with canonical result (R008)', () => { + registry.register({ + name: 'rancher', + namespace: 'farm', + type: 'agent', + filePath: '/farm/rancher/AGENT.md', + source: 'plugin:farm', + description: 'Farm agent', + metadata: {}, + }); + + const result = resolver.resolve('farm:rancher'); + + assert.strictEqual(result.resolution, 'canonical'); + if (result.resolution !== 'canonical') throw new Error('Type guard'); + + assert.strictEqual(result.component.canonicalName, 'farm:rancher'); + assert.strictEqual(result.component.type, 'agent'); + }); + + it('should return not-found for non-existent canonical name', () => { + const result = resolver.resolve('nonexistent:skill'); + assert.strictEqual(result.resolution, 'not-found'); + }); + + it('should return not-found for canonical name with wrong type filter', () => { + registry.register({ + name: 'call-horse', + namespace: 'farm', + type: 'skill', + filePath: '/farm/call-horse/SKILL.md', + source: 'plugin:farm', + description: 'Calls a horse', + metadata: {}, + }); + + const result = resolver.resolve('farm:call-horse', undefined, 'agent'); + assert.strictEqual(result.resolution, 'not-found'); + }); + }); + + describe('local-first resolution (D003)', () => { + it('should resolve bare name local-first when caller namespace has match', () => { + registry.register({ + name: 'call-horse', + namespace: 'farm', + type: 'skill', + filePath: '/farm/call-horse/SKILL.md', + source: 'plugin:farm', + description: 'Farm horse caller', + metadata: {}, + }); + registry.register({ + name: 'call-horse', + namespace: 'zoo', + type: 'skill', + filePath: '/zoo/call-horse/SKILL.md', + source: 'plugin:zoo', + description: 'Zoo horse caller', + metadata: {}, + }); + + const result = resolver.resolve('call-horse', { callerNamespace: 'farm' }); + + assert.strictEqual(result.resolution, 'local-first'); + if (result.resolution !== 'local-first') throw new Error('Type guard'); + + assert.strictEqual(result.requestedName, 'call-horse'); + assert.strictEqual(result.component.canonicalName, 'farm:call-horse'); + assert.strictEqual(result.matchedNamespace, 'farm'); + }); + + it('should resolve local-first from zoo namespace context', () => { + registry.register({ + name: 'call-horse', + namespace: 'farm', + type: 'skill', + filePath: '/farm/call-horse/SKILL.md', + source: 'plugin:farm', + description: 'Farm horse caller', + metadata: {}, + }); + registry.register({ + name: 'call-horse', + namespace: 'zoo', + type: 'skill', + filePath: '/zoo/call-horse/SKILL.md', + source: 'plugin:zoo', + description: 'Zoo horse caller', + metadata: {}, + }); + + const result = resolver.resolve('call-horse', { callerNamespace: 'zoo' }); + + assert.strictEqual(result.resolution, 'local-first'); + if (result.resolution !== 'local-first') throw new Error('Type guard'); + + assert.strictEqual(result.component.canonicalName, 'zoo:call-horse'); + }); + + it('should fall through to shorthand when local namespace has no match', () => { + registry.register({ + name: 'feed-chickens', + namespace: 'farm', + type: 'skill', + filePath: '/farm/feed-chickens/SKILL.md', + source: 'plugin:farm', + description: 'Feed chickens', + metadata: {}, + }); + + const result = resolver.resolve('feed-chickens', { callerNamespace: 'zoo' }); + + assert.strictEqual(result.resolution, 'shorthand'); + if (result.resolution !== 'shorthand') throw new Error('Type guard'); + + assert.strictEqual(result.component.canonicalName, 'farm:feed-chickens'); + }); + + it('should respect type filter in local-first resolution', () => { + // Register two different names - one skill, one agent + registry.register({ + name: 'helper-skill', + namespace: 'farm', + type: 'skill', + filePath: '/farm/helper-skill/SKILL.md', + source: 'plugin:farm', + description: 'Helper skill', + metadata: {}, + }); + registry.register({ + name: 'helper-agent', + namespace: 'farm', + type: 'agent', + filePath: '/farm/helper-agent/AGENT.md', + source: 'plugin:farm', + description: 'Helper agent', + metadata: {}, + }); + + // Request skill - should find helper-skill + const skillResult = resolver.resolve('helper-skill', { callerNamespace: 'farm' }, 'skill'); + assert.strictEqual(skillResult.resolution, 'local-first'); + if (skillResult.resolution !== 'local-first') throw new Error('Type guard'); + assert.strictEqual(skillResult.component.type, 'skill'); + assert.strictEqual(skillResult.component.name, 'helper-skill'); + + // Request agent - should find helper-agent + const agentResult = resolver.resolve('helper-agent', { callerNamespace: 'farm' }, 'agent'); + assert.strictEqual(agentResult.resolution, 'local-first'); + if (agentResult.resolution !== 'local-first') throw new Error('Type guard'); + assert.strictEqual(agentResult.component.type, 'agent'); + assert.strictEqual(agentResult.component.name, 'helper-agent'); + }); + }); + + describe('shorthand resolution (R009)', () => { + it('should resolve unambiguous shorthand with single match', () => { + registry.register({ + name: 'feed-chickens', + namespace: 'farm', + type: 'skill', + filePath: '/farm/feed-chickens/SKILL.md', + source: 'plugin:farm', + description: 'Feed chickens', + metadata: {}, + }); + + const result = resolver.resolve('feed-chickens'); + + assert.strictEqual(result.resolution, 'shorthand'); + if (result.resolution !== 'shorthand') throw new Error('Type guard'); + + assert.strictEqual(result.requestedName, 'feed-chickens'); + assert.strictEqual(result.component.canonicalName, 'farm:feed-chickens'); + }); + + it('should return ambiguous with candidates for multiple matches', () => { + registry.register({ + name: 'call-horse', + namespace: 'farm', + type: 'skill', + filePath: '/farm/call-horse/SKILL.md', + source: 'plugin:farm', + description: 'Farm horse caller', + metadata: {}, + }); + registry.register({ + name: 'call-horse', + namespace: 'zoo', + type: 'skill', + filePath: '/zoo/call-horse/SKILL.md', + source: 'plugin:zoo', + description: 'Zoo horse caller', + metadata: {}, + }); + + const result = resolver.resolve('call-horse'); + + assert.strictEqual(result.resolution, 'ambiguous'); + if (result.resolution !== 'ambiguous') throw new Error('Type guard'); + + assert.strictEqual(result.requestedName, 'call-horse'); + assert.strictEqual(result.candidates.length, 2); + + const canonicalNames = result.candidates.map((c) => c.canonicalName).sort(); + assert.deepStrictEqual(canonicalNames, ['farm:call-horse', 'zoo:call-horse']); + }); + + it('should return not-found for non-existent bare name', () => { + const result = resolver.resolve('nonexistent'); + assert.strictEqual(result.resolution, 'not-found'); + }); + + it('should return not-found when type filter eliminates all matches', () => { + registry.register({ + name: 'helper', + namespace: 'farm', + type: 'skill', + filePath: '/farm/helper/SKILL.md', + source: 'plugin:farm', + description: 'Helper skill', + metadata: {}, + }); + + const result = resolver.resolve('helper', undefined, 'agent'); + assert.strictEqual(result.resolution, 'not-found'); + }); + }); + + describe('flat component compatibility', () => { + it('should resolve flat component by bare name (no namespace)', () => { + registry.register({ + name: 'code-review', + namespace: undefined, + type: 'skill', + filePath: '/skills/code-review/SKILL.md', + source: 'user', + description: 'Code review skill', + metadata: {}, + }); + + const result = resolver.resolve('code-review'); + + assert.strictEqual(result.resolution, 'shorthand'); + if (result.resolution !== 'shorthand') throw new Error('Type guard'); + + assert.strictEqual(result.component.canonicalName, 'code-review'); + assert.strictEqual(result.component.namespace, undefined); + }); + + it('should include flat component in ambiguous candidates', () => { + registry.register({ + name: 'helper', + namespace: undefined, + type: 'skill', + filePath: '/skills/helper/SKILL.md', + source: 'user', + description: 'User helper', + metadata: {}, + }); + registry.register({ + name: 'helper', + namespace: 'farm', + type: 'skill', + filePath: '/farm/helper/SKILL.md', + source: 'plugin:farm', + description: 'Farm helper', + metadata: {}, + }); + + const result = resolver.resolve('helper'); + + assert.strictEqual(result.resolution, 'ambiguous'); + if (result.resolution !== 'ambiguous') throw new Error('Type guard'); + + assert.strictEqual(result.candidates.length, 2); + const canonicalNames = result.candidates.map((c) => c.canonicalName).sort(); + assert.deepStrictEqual(canonicalNames, ['farm:helper', 'helper']); + }); + }); + + describe('type filtering', () => { + it('should filter by skill type across namespaces', () => { + // Register skill in one namespace + registry.register({ + name: 'review', + namespace: 'tools', + type: 'skill', + filePath: '/tools/review/SKILL.md', + source: 'plugin:tools', + description: 'Review skill', + metadata: {}, + }); + // Register agent in another namespace (different canonical name) + registry.register({ + name: 'review', + namespace: 'agents', + type: 'agent', + filePath: '/agents/review/AGENT.md', + source: 'plugin:agents', + description: 'Review agent', + metadata: {}, + }); + + // Both have same bare name, filtering by type disambiguates + const skillResult = resolver.resolve('review', undefined, 'skill'); + assert.strictEqual(skillResult.resolution, 'shorthand'); + if (skillResult.resolution !== 'shorthand') throw new Error('Type guard'); + assert.strictEqual(skillResult.component.type, 'skill'); + assert.strictEqual(skillResult.component.namespace, 'tools'); + + const agentResult = resolver.resolve('review', undefined, 'agent'); + assert.strictEqual(agentResult.resolution, 'shorthand'); + if (agentResult.resolution !== 'shorthand') throw new Error('Type guard'); + assert.strictEqual(agentResult.component.type, 'agent'); + assert.strictEqual(agentResult.component.namespace, 'agents'); + }); + + it('should resolve unique skill among multiple agents with same name', () => { + registry.register({ + name: 'assistant', + namespace: 'tools', + type: 'skill', + filePath: '/tools/assistant/SKILL.md', + source: 'plugin:tools', + description: 'Assistant skill', + metadata: {}, + }); + registry.register({ + name: 'assistant', + namespace: 'other', + type: 'agent', + filePath: '/other/assistant/AGENT.md', + source: 'plugin:other', + description: 'Assistant agent', + metadata: {}, + }); + + const result = resolver.resolve('assistant', undefined, 'skill'); + assert.strictEqual(result.resolution, 'shorthand'); + if (result.resolution !== 'shorthand') throw new Error('Type guard'); + assert.strictEqual(result.component.canonicalName, 'tools:assistant'); + }); + }); + + describe('resolution path diagnostics', () => { + it('should include requestedName in all result types', () => { + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/skill/SKILL.md', + source: 'test', + description: undefined, + metadata: {}, + }); + + const canon = resolver.resolve('ns:skill'); + assert.strictEqual(canon.requestedName, 'ns:skill'); + + const local = resolver.resolve('skill', { callerNamespace: 'ns' }); + assert.strictEqual(local.requestedName, 'skill'); + + const short = resolver.resolve('skill'); + assert.strictEqual(short.requestedName, 'skill'); + + const notFound = resolver.resolve('missing'); + assert.strictEqual(notFound.requestedName, 'missing'); + }); + + it('should provide matchedNamespace in local-first results', () => { + registry.register({ + name: 'skill', + namespace: 'my-ns', + type: 'skill', + filePath: '/skill/SKILL.md', + source: 'test', + description: undefined, + metadata: {}, + }); + + const result = resolver.resolve('skill', { callerNamespace: 'my-ns' }); + assert.strictEqual(result.resolution, 'local-first'); + + if (result.resolution === 'local-first') { + assert.strictEqual(result.matchedNamespace, 'my-ns'); + } + }); + + it('should provide full candidate list in ambiguous results', () => { + registry.register({ + name: 'dup', + namespace: 'a', + type: 'skill', + filePath: '/a/dup/SKILL.md', + source: 'a', + description: 'A dup', + metadata: {}, + }); + registry.register({ + name: 'dup', + namespace: 'b', + type: 'skill', + filePath: '/b/dup/SKILL.md', + source: 'b', + description: 'B dup', + metadata: {}, + }); + + const result = resolver.resolve('dup'); + assert.strictEqual(result.resolution, 'ambiguous'); + + if (result.resolution === 'ambiguous') { + assert.strictEqual(result.candidates.length, 2); + for (const candidate of result.candidates) { + assert.ok(candidate.canonicalName); + assert.ok(candidate.filePath); + assert.strictEqual(candidate.name, 'dup'); + } + } + }); + }); + + describe('edge cases', () => { + it('should handle empty registry gracefully', () => { + const result = resolver.resolve('anything'); + assert.strictEqual(result.resolution, 'not-found'); + }); + + it('should handle empty caller namespace string', () => { + registry.register({ + name: 'skill', + namespace: 'ns', + type: 'skill', + filePath: '/skill/SKILL.md', + source: 'test', + description: undefined, + metadata: {}, + }); + + // Empty string is falsy, should fall through to shorthand + const result = resolver.resolve('skill', { callerNamespace: '' }); + assert.strictEqual(result.resolution, 'shorthand'); + }); + }); + + describe('alias resolution', () => { + it('should resolve alias with alias result type', () => { + registry.register({ + name: '3d-visualizer', + namespace: 'python-tools', + type: 'skill', + filePath: '/python-tools/3d-visualizer/SKILL.md', + source: 'plugin:python-tools', + description: '3D visualization', + metadata: {}, + }); + registry.registerAlias('py3d', 'python-tools:3d-visualizer'); + + const result = resolver.resolve('py3d'); + + assert.strictEqual(result.resolution, 'alias'); + if (result.resolution !== 'alias') throw new Error('Type guard'); + + assert.strictEqual(result.requestedName, 'py3d'); + assert.strictEqual(result.alias, 'py3d'); + assert.strictEqual(result.canonicalName, 'python-tools:3d-visualizer'); + assert.strictEqual(result.component.canonicalName, 'python-tools:3d-visualizer'); + assert.strictEqual(result.component.type, 'skill'); + }); + + it('should respect type filter in alias resolution', () => { + registry.register({ + name: 'visualizer', + namespace: 'tools', + type: 'skill', + filePath: '/tools/visualizer/SKILL.md', + source: 'plugin:tools', + description: 'Visualizer skill', + metadata: {}, + }); + registry.registerAlias('viz', 'tools:visualizer'); + + // Type filter matches - should resolve + const skillResult = resolver.resolve('viz', undefined, 'skill'); + assert.strictEqual(skillResult.resolution, 'alias'); + if (skillResult.resolution !== 'alias') throw new Error('Type guard'); + assert.strictEqual(skillResult.component.type, 'skill'); + + // Type filter doesn't match - should not resolve alias + const agentResult = resolver.resolve('viz', undefined, 'agent'); + assert.strictEqual(agentResult.resolution, 'not-found'); + }); + + it('should prioritize alias over shorthand (alias checked first)', () => { + // Register a component that could match as shorthand + registry.register({ + name: 'shortcut', + namespace: 'other-plugin', + type: 'skill', + filePath: '/other/shortcut/SKILL.md', + source: 'plugin:other-plugin', + description: 'Other shortcut', + metadata: {}, + }); + + // Register a different component with an alias using the same bare name + registry.register({ + name: 'aliased-skill', + namespace: 'main-plugin', + type: 'skill', + filePath: '/main/aliased-skill/SKILL.md', + source: 'plugin:main-plugin', + description: 'Main skill', + metadata: {}, + }); + registry.registerAlias('shortcut', 'main-plugin:aliased-skill'); + + // 'shortcut' should resolve via alias, not shorthand + const result = resolver.resolve('shortcut'); + + assert.strictEqual(result.resolution, 'alias'); + if (result.resolution !== 'alias') throw new Error('Type guard'); + + // Should point to the aliased target, not the shorthand match + assert.strictEqual(result.canonicalName, 'main-plugin:aliased-skill'); + }); + + it('should prioritize alias over local-first (alias checked first)', () => { + // Register components in two namespaces + registry.register({ + name: 'helper', + namespace: 'local-ns', + type: 'skill', + filePath: '/local-ns/helper/SKILL.md', + source: 'plugin:local-ns', + description: 'Local helper', + metadata: {}, + }); + registry.register({ + name: 'aliased-helper', + namespace: 'alias-ns', + type: 'skill', + filePath: '/alias-ns/aliased-helper/SKILL.md', + source: 'plugin:alias-ns', + description: 'Aliased helper', + metadata: {}, + }); + + // Create alias that shadows local namespace name + registry.registerAlias('helper', 'alias-ns:aliased-helper'); + + // Even with callerNamespace='local-ns', alias should win + const result = resolver.resolve('helper', { callerNamespace: 'local-ns' }); + + assert.strictEqual(result.resolution, 'alias'); + if (result.resolution !== 'alias') throw new Error('Type guard'); + assert.strictEqual(result.canonicalName, 'alias-ns:aliased-helper'); + }); + + it('should include alias and canonicalName in result', () => { + registry.register({ + name: 'code-review', + namespace: 'tools', + type: 'agent', + filePath: '/tools/code-review/AGENT.md', + source: 'plugin:tools', + description: 'Code review agent', + metadata: {}, + }); + registry.registerAlias('review', 'tools:code-review'); + + const result = resolver.resolve('review'); + + assert.strictEqual(result.resolution, 'alias'); + if (result.resolution !== 'alias') throw new Error('Type guard'); + + // Both alias and canonicalName should be present + assert.strictEqual(result.alias, 'review'); + assert.strictEqual(result.canonicalName, 'tools:code-review'); + assert.strictEqual(result.component.canonicalName, 'tools:code-review'); + }); + + it('should fall through to local-first/shorthand when alias does not exist', () => { + registry.register({ + name: 'existing', + namespace: 'ns', + type: 'skill', + filePath: '/ns/existing/SKILL.md', + source: 'plugin:ns', + description: 'Existing skill', + metadata: {}, + }); + + // No alias registered, should fall through to local-first + const result = resolver.resolve('existing', { callerNamespace: 'ns' }); + + assert.strictEqual(result.resolution, 'local-first'); + if (result.resolution !== 'local-first') throw new Error('Type guard'); + assert.strictEqual(result.component.canonicalName, 'ns:existing'); + }); + + it('should fall through to shorthand when alias does not exist and no local match', () => { + registry.register({ + name: 'unique', + namespace: 'plugin-a', + type: 'skill', + filePath: '/plugin-a/unique/SKILL.md', + source: 'plugin:plugin-a', + description: 'Unique skill', + metadata: {}, + }); + + // No alias registered, no local match, should fall through to shorthand + const result = resolver.resolve('unique', { callerNamespace: 'other-ns' }); + + assert.strictEqual(result.resolution, 'shorthand'); + if (result.resolution !== 'shorthand') throw new Error('Type guard'); + assert.strictEqual(result.component.canonicalName, 'plugin-a:unique'); + }); + }); +}); diff --git a/src/resources/extensions/gsd/tests/plugin-importer-live.test.ts b/src/resources/extensions/gsd/tests/plugin-importer-live.test.ts new file mode 100644 index 000000000..b7caf1dab --- /dev/null +++ b/src/resources/extensions/gsd/tests/plugin-importer-live.test.ts @@ -0,0 +1,479 @@ +/** + * Live E2E Tests Against Real Marketplace Repos + * + * Tests R014: Validates PluginImporter pipeline against real marketplace data. + * Uses ../claude_skills and ../claude-plugins-official directories. + * + * Skips gracefully when repos are absent (CI-safe). + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { PluginImporter, type DiscoveryResult, type ImportManifest } from '../plugin-importer.js'; +import { getMarketplaceFixtures } from './marketplace-test-fixtures.js'; + +// ============================================================================ +// Live Test Configuration +// ============================================================================ + +/** + * Canonical name format regex: namespace:name or bare name + * Allows alphanumeric, underscore, hyphen, and dot in names. + * Real marketplace data has names like "ecosystem-researcher-v1.1-rt-ica". + */ +const CANONICAL_NAME_REGEX = /^[a-zA-Z0-9_.-]+(?::[a-zA-Z0-9_.-]+)?$/; + +// ============================================================================ +// Live E2E Tests +// ============================================================================ + +const fixtureSetup = getMarketplaceFixtures(import.meta.dirname); +const fixtures = fixtureSetup.fixtures; +const CLAUDE_SKILLS_PATH = fixtures?.claudeSkillsPath; +const CLAUDE_PLUGINS_OFFICIAL_PATH = fixtures?.claudePluginsOfficialPath; + +// Log marketplace status for observability +console.log('Live E2E Test Configuration:'); +console.log(` source: ${fixtures?.source ?? 'unavailable'}`); +if (CLAUDE_SKILLS_PATH) { + console.log(` claude_skills: FOUND at ${CLAUDE_SKILLS_PATH}`); +} +if (CLAUDE_PLUGINS_OFFICIAL_PATH) { + console.log(` claude-plugins-official: FOUND at ${CLAUDE_PLUGINS_OFFICIAL_PATH}`); +} +if (!fixtureSetup.available) { + console.log(` unavailable: ${fixtureSetup.skipReason}`); +} + +const skipReason = !fixtureSetup.available ? fixtureSetup.skipReason : undefined; + +describe( + 'Live E2E Tests', + { skip: skipReason }, + () => { + let importer: PluginImporter; + let discoveryResult: DiscoveryResult; + + before(() => { + importer = new PluginImporter(); + }); + + after(() => { + fixtures?.cleanup(); + }); + + describe('Step 2: discover() against real marketplaces', () => { + it('should discover plugins from both marketplaces with no fatal errors', () => { + // Stage 1: Discover + discoveryResult = importer.discover([ + CLAUDE_SKILLS_PATH!, + CLAUDE_PLUGINS_OFFICIAL_PATH!, + ]); + + // Log discovery summary for observability + console.log('\nDiscovery Summary:'); + console.log(` Marketplaces processed: ${discoveryResult.summary.marketplacesProcessed}`); + console.log(` Total plugins: ${discoveryResult.summary.totalPlugins}`); + console.log(` Total components: ${discoveryResult.summary.totalComponents}`); + console.log(` Marketplaces with errors: ${discoveryResult.summary.marketplacesWithErrors}`); + + // Assert positive counts + assert.ok( + discoveryResult.summary.totalPlugins > 0, + 'Should find at least one plugin across both marketplaces' + ); + + assert.ok( + discoveryResult.summary.totalComponents > 0, + 'Should discover at least one component across both marketplaces' + ); + + // No fatal errors should crash the pipeline + assert.strictEqual( + discoveryResult.summary.marketplacesProcessed, + 2, + 'Should process both marketplace paths' + ); + }); + + it('should have processed both marketplace.json files', () => { + assert.ok(discoveryResult, 'Discovery must run first'); + + // Both marketplaces should have been attempted + assert.strictEqual( + discoveryResult.marketplaceResults.length, + 2, + 'Should have results for both marketplaces' + ); + + // At least one should have succeeded (they're real repos) + const successfulMarketplaces = discoveryResult.marketplaceResults.filter( + (m) => m.status === 'ok' + ); + + assert.ok( + successfulMarketplaces.length >= 1, + 'At least one marketplace should have loaded successfully' + ); + }); + }); + + describe('Step 3: canonical name format validation', () => { + it('should have valid canonical names matching namespace:component format', () => { + assert.ok(discoveryResult, 'Discovery must run first'); + + const registry = importer.getRegistry(); + assert.ok(registry, 'Registry should be populated'); + + const allComponents = registry.getAll(); + + // Should have components from real plugins + assert.ok( + allComponents.length > 0, + 'Should have discovered components to validate' + ); + + // Log sample canonical names for observability + const sampleNames = allComponents.slice(0, 5).map((c) => c.canonicalName); + console.log('\nSample canonical names from discovered components:'); + sampleNames.forEach((name) => console.log(` - ${name}`)); + + // Validate each canonical name + for (const component of allComponents) { + assert.ok( + CANONICAL_NAME_REGEX.test(component.canonicalName), + `Canonical name "${component.canonicalName}" should match format "namespace:name" or bare "name"` + ); + + // Namespaced components should have colon in canonical name + if (component.namespace) { + assert.ok( + component.canonicalName.includes(':'), + `Namespaced component "${component.canonicalName}" should contain colon` + ); + + // Canonical should be namespace:name + const expected = `${component.namespace}:${component.name}`; + assert.strictEqual( + component.canonicalName, + expected, + `Canonical name should equal namespace:name` + ); + } else { + // Flat components should NOT have colon + assert.ok( + !component.canonicalName.includes(':'), + `Flat component "${component.canonicalName}" should not contain colon` + ); + + assert.strictEqual( + component.canonicalName, + component.name, + `Flat component canonical should equal bare name` + ); + } + } + }); + }); + + describe('Step 4: selectComponents() filtering', () => { + it('should filter components by type and return non-empty results', () => { + assert.ok(discoveryResult, 'Discovery must run first'); + + // Filter by skills + const skills = importer.selectComponents((c) => c.type === 'skill'); + + // Filter by agents + const agents = importer.selectComponents((c) => c.type === 'agent'); + + console.log('\nComponent type counts:'); + console.log(` Skills: ${skills.length}`); + console.log(` Agents: ${agents.length}`); + + // At least one type should have components (real marketplaces have plugins) + assert.ok( + skills.length > 0 || agents.length > 0, + 'At least one component type should have results from real marketplaces' + ); + }); + + it('should filter by namespace correctly', () => { + assert.ok(discoveryResult, 'Discovery must run first'); + + const registry = importer.getRegistry(); + const allComponents = registry!.getAll(); + + // Get unique namespaces + const namespaces = new Set( + allComponents.map((c) => c.namespace).filter((n): n is string => n !== undefined) + ); + + console.log('\nDiscovered namespaces:'); + namespaces.forEach((ns) => console.log(` - ${ns}`)); + + if (namespaces.size > 0) { + // Pick a namespace and filter + const testNamespace = Array.from(namespaces)[0]!; + const filtered = importer.selectComponents( + (c) => c.namespace === testNamespace + ); + + assert.ok( + filtered.length > 0, + `Should find components for namespace "${testNamespace}"` + ); + + // All results should match the filter + for (const comp of filtered) { + assert.strictEqual( + comp.namespace, + testNamespace, + 'Filtered components should have correct namespace' + ); + } + } + }); + }); + + describe('Step 5: validateImport() on real data', () => { + it('should run validation on all discovered components without crash', () => { + assert.ok(discoveryResult, 'Discovery must run first'); + + const registry = importer.getRegistry(); + const allComponents = registry!.getAll(); + + // Run validation on all discovered components + const validation = importer.validateImport(allComponents); + + console.log('\nValidation result:'); + console.log(` Can proceed: ${validation.canProceed}`); + console.log(` Total diagnostics: ${validation.summary.total}`); + console.log(` Errors: ${validation.summary.errors}`); + console.log(` Warnings: ${validation.summary.warnings}`); + + if (validation.diagnostics.length > 0) { + console.log('\nDiagnostics:'); + validation.diagnostics.forEach((d) => { + console.log(` [${d.severity}] ${d.class}: ${d.remediation}`); + }); + } + + // Validation should complete without throwing + assert.ok(validation, 'Validation should return a result'); + assert.ok( + typeof validation.canProceed === 'boolean', + 'canProceed should be boolean' + ); + assert.ok( + Array.isArray(validation.diagnostics), + 'diagnostics should be an array' + ); + }); + + it('should have valid diagnostic structure if warnings exist', () => { + const validation = importer.getLastValidation(); + assert.ok(validation, 'Validation should have run'); + + for (const diag of validation.diagnostics) { + // Verify diagnostic structure + assert.ok(diag.class, 'Diagnostic should have class'); + assert.ok( + ['error', 'warning'].includes(diag.severity), + 'Diagnostic severity should be error or warning' + ); + assert.ok(diag.remediation, 'Diagnostic should have remediation'); + assert.ok( + Array.isArray(diag.involvedCanonicalNames), + 'Diagnostic should have involvedCanonicalNames array' + ); + assert.ok( + Array.isArray(diag.filePaths), + 'Diagnostic should have filePaths array' + ); + } + }); + + it('should not have error-severity diagnostics blocking on real data (data quality check)', () => { + const validation = importer.getLastValidation(); + assert.ok(validation, 'Validation should have run'); + + // Real marketplace data should not have fatal canonical collisions + // (this is a data quality assertion) + if (validation.summary.errors > 0) { + console.log('\nWARNING: Real marketplace data has error-severity diagnostics!'); + console.log('This may indicate duplicate canonical names in the marketplace.'); + + // Log the errors for investigation + validation.diagnostics + .filter((d) => d.severity === 'error') + .forEach((d) => { + console.log(` ERROR: ${d.class}`); + console.log(` Involved: ${d.involvedCanonicalNames.join(', ')}`); + console.log(` Files: ${d.filePaths.join(', ')}`); + }); + } + + // Note: We allow errors in assertion but log them for visibility + // Real data might have collisions, but the pipeline should handle them + assert.strictEqual(typeof validation.canProceed, 'boolean'); + }); + }); + + describe('Step 6: getImportManifest() with canonical names', () => { + it('should generate manifest preserving canonical names from real plugins', () => { + assert.ok(discoveryResult, 'Discovery must run first'); + + const registry = importer.getRegistry(); + const allComponents = registry!.getAll(); + + // Generate manifest for all components + const manifest = importer.getImportManifest(allComponents); + + console.log('\nManifest summary:'); + console.log(` Schema version: ${manifest.schemaVersion}`); + console.log(` Total entries: ${manifest.summary.total}`); + console.log(` Skills: ${manifest.summary.skills}`); + console.log(` Agents: ${manifest.summary.agents}`); + console.log(` Namespaces: ${manifest.summary.namespaces.length}`); + + // Verify manifest structure + assert.strictEqual(manifest.schemaVersion, '1.0'); + assert.strictEqual( + manifest.entries.length, + allComponents.length, + 'Manifest should have entry for each component' + ); + + // Verify canonical names preserved + for (const entry of manifest.entries) { + // Find matching component + const component = allComponents.find( + (c) => c.canonicalName === entry.canonicalName + ); + + assert.ok( + component, + `Manifest entry should match component: ${entry.canonicalName}` + ); + + // Canonical name should match exactly + assert.strictEqual( + entry.canonicalName, + component.canonicalName, + 'Canonical name should be preserved in manifest' + ); + + // Type should match + assert.strictEqual(entry.type, component.type); + + // Namespace should match + assert.strictEqual(entry.namespace, component.namespace); + + // Name should match + assert.strictEqual(entry.name, component.name); + + // File path should be preserved + assert.strictEqual(entry.filePath, component.filePath); + } + }); + + it('should produce JSON-serializable manifest', () => { + const registry = importer.getRegistry(); + const allComponents = registry!.getAll(); + + const manifest = importer.getImportManifest(allComponents); + + // Should be JSON serializable + const json = JSON.stringify(manifest, null, 2); + + // Should parse back correctly + const parsed: ImportManifest = JSON.parse(json); + + assert.strictEqual(parsed.schemaVersion, manifest.schemaVersion); + assert.strictEqual(parsed.entries.length, manifest.entries.length); + + // Sample entries should match after round-trip + const sampleEntry = parsed.entries[0]; + if (sampleEntry) { + const original = manifest.entries[0]!; + assert.strictEqual(sampleEntry.canonicalName, original.canonicalName); + assert.strictEqual(sampleEntry.type, original.type); + } + }); + + it('should have correct summary counts', () => { + const registry = importer.getRegistry(); + const allComponents = registry!.getAll(); + + const manifest = importer.getImportManifest(allComponents); + + // Count skills and agents + const skillCount = manifest.entries.filter((e) => e.type === 'skill').length; + const agentCount = manifest.entries.filter((e) => e.type === 'agent').length; + + assert.strictEqual( + manifest.summary.skills, + skillCount, + 'Skill count should match entries' + ); + + assert.strictEqual( + manifest.summary.agents, + agentCount, + 'Agent count should match entries' + ); + + assert.strictEqual( + manifest.summary.total, + manifest.entries.length, + 'Total should match entry count' + ); + + // Namespaces should be unique and sorted + const uniqueNamespaces = new Set( + manifest.entries + .map((e) => e.namespace) + .filter((n): n is string => n !== undefined) + ); + + assert.deepStrictEqual( + manifest.summary.namespaces, + Array.from(uniqueNamespaces).sort(), + 'Namespaces should be unique and sorted' + ); + }); + }); + + describe('Full pipeline verification', () => { + it('should execute discover → select → validate → manifest without error', () => { + // This test verifies the full pipeline works end-to-end + + // Already have discovery from before() + assert.ok(discoveryResult, 'Discovery should have completed'); + + // Select subset + const skills = importer.selectComponents((c) => c.type === 'skill'); + + // Validate + const validation = importer.validateImport(skills); + assert.ok(validation, 'Validation should complete'); + + // Generate manifest + const manifest = importer.getImportManifest(skills); + assert.ok(manifest, 'Manifest generation should complete'); + + // All skills should be in manifest + assert.strictEqual( + manifest.summary.skills, + skills.length, + 'All selected skills should be in manifest' + ); + + console.log('\nFull pipeline verification:'); + console.log(` Selected: ${skills.length} skills`); + console.log(` Validated: canProceed=${validation.canProceed}`); + console.log(` Manifest: ${manifest.summary.total} entries`); + }); + }); + } +); diff --git a/src/resources/extensions/gsd/tests/plugin-importer.test.ts b/src/resources/extensions/gsd/tests/plugin-importer.test.ts new file mode 100644 index 000000000..3d0bddcca --- /dev/null +++ b/src/resources/extensions/gsd/tests/plugin-importer.test.ts @@ -0,0 +1,1383 @@ +/** + * PluginImporter Contract Tests + * + * Tests that prove R012 (discover/select/import flow) and R013 (canonical name preservation). + * + * Coverage: + * - Discovery pipeline: marketplace discovery → registry population + * - Selective filtering: filter function correctly selects components + * - Diagnostic gating: errors block, warnings pass + * - Config manifest format: canonical identity preserved + */ + +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert'; +import { + PluginImporter, + type DiscoveryResult, + type ValidationResult, + type ImportManifest, +} from '../plugin-importer.js'; +import type { NamespacedComponent } from '../namespaced-registry.js'; +import type { + MarketplaceDiscoveryResult, + DiscoveredPlugin, +} from '../marketplace-discovery.js'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +/** + * Create a mock discovered plugin for testing. + */ +function createMockPlugin(overrides: Partial = {}): DiscoveredPlugin { + return { + name: 'test-plugin', + canonicalName: 'test-plugin', + source: './plugins/test-plugin', + resolvedPath: '/plugins/test-plugin', + status: 'ok', + manifestSource: 'plugin.json', + description: 'A test plugin', + version: '1.0.0', + author: { name: 'Test Author' }, + inventory: { + skills: ['skill-a', 'skill-b'], + agents: ['agent-x'], + commands: [], + mcpServers: {}, + lspServers: {}, + hooks: [], + }, + ...overrides, + }; +} + +/** + * Create a mock marketplace discovery result. + */ +function createMockDiscoveryResult( + plugins: DiscoveredPlugin[] = [], + overrides: Partial = {} +): MarketplaceDiscoveryResult { + return { + status: 'ok', + marketplacePath: '/test/marketplace.json', + marketplaceName: 'Test Marketplace', + pluginFormat: 'jamie-style', + plugins, + summary: { + total: plugins.length, + ok: plugins.filter((p) => p.status === 'ok').length, + error: plugins.filter((p) => p.status === 'error').length, + }, + ...overrides, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('PluginImporter', () => { + let importer: PluginImporter; + + beforeEach(() => { + importer = new PluginImporter(); + }); + + describe('Stage 1: discover()', () => { + it('should throw error if paths array is empty but return valid result', () => { + const result = importer.discover([]); + + assert.strictEqual(result.summary.marketplacesProcessed, 0); + assert.strictEqual(result.summary.totalPlugins, 0); + assert.strictEqual(result.summary.totalComponents, 0); + }); + + it('should call discoverMarketplace for each path and aggregate results', () => { + // Test with non-existent paths - should still return structure + const result = importer.discover([ + '/nonexistent/marketplace-1', + '/nonexistent/marketplace-2', + ]); + + assert.strictEqual(result.summary.marketplacesProcessed, 2); + assert.strictEqual(Array.isArray(result.marketplaceResults), true); + assert.strictEqual(result.marketplaceResults.length, 2); + }); + + it('should populate registry via componentsFromDiscovery', () => { + // Test against a real path if it exists, otherwise test structure + const result = importer.discover([]); + + // Registry should be populated (even if empty) + const registry = importer.getRegistry(); + assert.ok(registry !== null); + assert.strictEqual(registry!.size, result.summary.totalComponents); + }); + + it('should track plugins with errors in summary', () => { + const result = importer.discover(['/nonexistent/path']); + + // Non-existent path should result in error status + assert.ok(result.summary.marketplacesWithErrors >= 0); + }); + + it('should be re-entrant (calling discover again resets state)', () => { + // First discovery + importer.discover(['/nonexistent/path-1']); + const firstPlugins = importer.getDiscoveredPlugins(); + + // Second discovery should reset + importer.discover(['/nonexistent/path-2']); + const secondPlugins = importer.getDiscoveredPlugins(); + + // Should have fresh state (not accumulated) + // Both should have 0 plugins since paths don't exist + assert.strictEqual(firstPlugins.length, 0); + assert.strictEqual(secondPlugins.length, 0); + }); + }); + + describe('Stage 2: selectComponents()', () => { + it('should throw error if called before discover()', () => { + assert.throws( + () => importer.selectComponents(() => true), + /Must call discover\(\) before selectComponents\(\)/ + ); + }); + + it('should return empty array if no components match filter', () => { + importer.discover([]); + const selected = importer.selectComponents(() => false); + assert.deepStrictEqual(selected, []); + }); + + it('should return all components if filter returns true', () => { + importer.discover([]); + const selected = importer.selectComponents(() => true); + // Empty discovery means no components + assert.deepStrictEqual(selected, []); + }); + + it('should filter by namespace correctly', () => { + importer.discover([]); + const selected = importer.selectComponents( + (c) => c.namespace === 'target-plugin' + ); + assert.deepStrictEqual(selected, []); + }); + + it('should filter by type correctly', () => { + importer.discover([]); + const skills = importer.selectComponents((c) => c.type === 'skill'); + const agents = importer.selectComponents((c) => c.type === 'agent'); + assert.deepStrictEqual(skills, []); + assert.deepStrictEqual(agents, []); + }); + + it('should filter by name pattern correctly', () => { + importer.discover([]); + const selected = importer.selectComponents((c) => + c.name.includes('review') + ); + assert.deepStrictEqual(selected, []); + }); + }); + + describe('Stage 3: validateImport()', () => { + it('should throw error if called before discover()', () => { + const components: NamespacedComponent[] = []; + assert.throws( + () => importer.validateImport(components), + /Must call discover\(\) before validateImport\(\)/ + ); + }); + + it('should return canProceed: true for empty selection', () => { + importer.discover([]); + const result = importer.validateImport([]); + + assert.strictEqual(result.canProceed, true); + assert.strictEqual(result.diagnostics.length, 0); + assert.strictEqual(result.summary.total, 0); + assert.strictEqual(result.summary.errors, 0); + assert.strictEqual(result.summary.warnings, 0); + }); + + it('should return canProceed: true when no collisions', () => { + importer.discover([]); + + // Create mock components without collisions + const components: NamespacedComponent[] = [ + { + name: 'skill-a', + namespace: 'plugin-x', + canonicalName: 'plugin-x:skill-a', + type: 'skill', + filePath: '/x/skill-a.md', + source: 'plugin:plugin-x', + description: undefined, + metadata: {}, + }, + { + name: 'skill-b', + namespace: 'plugin-y', + canonicalName: 'plugin-y:skill-b', + type: 'skill', + filePath: '/y/skill-b.md', + source: 'plugin:plugin-y', + description: undefined, + metadata: {}, + }, + ]; + + const result = importer.validateImport(components); + + assert.strictEqual(result.canProceed, true); + }); + + it('should detect canonical collision and return canProceed: false (error blocks)', () => { + importer.discover([]); + + // Create components with same canonical name (collision) + const components: NamespacedComponent[] = [ + { + name: 'skill-a', + namespace: 'plugin-x', + canonicalName: 'plugin-x:skill-a', + type: 'skill', + filePath: '/first/skill-a.md', + source: 'plugin:plugin-x', + description: undefined, + metadata: {}, + }, + { + name: 'skill-a', + namespace: 'plugin-x', + canonicalName: 'plugin-x:skill-a', // Same canonical name! + type: 'skill', + filePath: '/second/skill-a.md', + source: 'plugin:plugin-x', + description: undefined, + metadata: {}, + }, + ]; + + const result = importer.validateImport(components); + + // Error severity should block + assert.strictEqual(result.canProceed, false); + assert.strictEqual(result.summary.errors, 1); + assert.ok(result.diagnostics.some((d) => d.severity === 'error')); + }); + + it('should detect shorthand overlap but return canProceed: true (warning passes)', () => { + importer.discover([]); + + // Create components with same bare name but different namespaces + const components: NamespacedComponent[] = [ + { + name: 'review', // Same bare name + namespace: 'plugin-a', + canonicalName: 'plugin-a:review', + type: 'skill', + filePath: '/a/review.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }, + { + name: 'review', // Same bare name + namespace: 'plugin-b', + canonicalName: 'plugin-b:review', + type: 'skill', + filePath: '/b/review.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }, + ]; + + const result = importer.validateImport(components); + + // Warning severity should NOT block + assert.strictEqual(result.canProceed, true); + assert.strictEqual(result.summary.errors, 0); + assert.strictEqual(result.summary.warnings, 1); + assert.ok(result.diagnostics.some((d) => d.severity === 'warning')); + }); + + it('should correctly classify severity: error for canonical conflict', () => { + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/1/dup.md', + source: 'first', + description: undefined, + metadata: {}, + }, + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/2/dup.md', + source: 'second', + description: undefined, + metadata: {}, + }, + ]; + + const result = importer.validateImport(components); + + const error = result.diagnostics.find((d) => d.severity === 'error'); + assert.ok(error !== undefined); + assert.strictEqual(error!.class, 'canonical-conflict'); + assert.ok(error!.involvedCanonicalNames.includes('ns:dup')); + }); + + it('should correctly classify severity: warning for shorthand overlap', () => { + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'common-skill', + namespace: 'plugin-a', + canonicalName: 'plugin-a:common-skill', + type: 'skill', + filePath: '/a/common.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }, + { + name: 'common-skill', + namespace: 'plugin-b', + canonicalName: 'plugin-b:common-skill', + type: 'skill', + filePath: '/b/common.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }, + ]; + + const result = importer.validateImport(components); + + const warning = result.diagnostics.find((d) => d.severity === 'warning'); + assert.ok(warning !== undefined); + assert.strictEqual(warning!.class, 'shorthand-overlap'); + assert.strictEqual(warning!.ambiguousBareName, 'common-skill'); + }); + }); + + describe('Stage 4: getImportManifest()', () => { + it('should produce valid manifest for empty selection', () => { + const manifest = importer.getImportManifest([]); + + assert.strictEqual(manifest.schemaVersion, '1.0'); + assert.strictEqual(typeof manifest.generatedAt, 'string'); + assert.deepStrictEqual(manifest.entries, []); + assert.strictEqual(manifest.summary.total, 0); + assert.strictEqual(manifest.summary.skills, 0); + assert.strictEqual(manifest.summary.agents, 0); + assert.deepStrictEqual(manifest.summary.namespaces, []); + }); + + it('should preserve canonical names in manifest (R013)', () => { + const components: NamespacedComponent[] = [ + { + name: 'code-review', + namespace: 'my-plugin', + canonicalName: 'my-plugin:code-review', + type: 'skill', + filePath: '/plugins/my-plugin/skills/code-review/SKILL.md', + source: 'plugin:my-plugin', + description: 'Reviews code', + metadata: { + pluginVersion: '1.0.0', + pluginAuthor: 'Test Author', + }, + }, + ]; + + const manifest = importer.getImportManifest(components); + + assert.strictEqual(manifest.entries.length, 1); + + // Verify canonical name preserved + const entry = manifest.entries[0]; + assert.strictEqual(entry!.canonicalName, 'my-plugin:code-review'); + assert.strictEqual(entry!.name, 'code-review'); + assert.strictEqual(entry!.namespace, 'my-plugin'); + }); + + it('should include all component metadata in manifest', () => { + const components: NamespacedComponent[] = [ + { + name: 'test-skill', + namespace: 'test-plugin', + canonicalName: 'test-plugin:test-skill', + type: 'skill', + filePath: '/test/skill.md', + source: 'plugin:test-plugin', + description: 'A test skill', + metadata: { + pluginVersion: '2.0.0', + pluginAuthor: 'Author Name', + pluginHomepage: 'https://example.com', + pluginCategory: 'testing', + }, + }, + ]; + + const manifest = importer.getImportManifest(components); + + const entry = manifest.entries[0]; + assert.ok(entry !== undefined); + assert.strictEqual(entry!.description, 'A test skill'); + assert.strictEqual(entry!.metadata.pluginVersion, '2.0.0'); + assert.strictEqual(entry!.metadata.pluginAuthor, 'Author Name'); + assert.strictEqual(entry!.metadata.pluginHomepage, 'https://example.com'); + assert.strictEqual(entry!.metadata.pluginCategory, 'testing'); + }); + + it('should count skills and agents separately in summary', () => { + const components: NamespacedComponent[] = [ + { + name: 'skill-a', + namespace: 'ns', + canonicalName: 'ns:skill-a', + type: 'skill', + filePath: '/a.md', + source: 'plugin:ns', + description: undefined, + metadata: {}, + }, + { + name: 'skill-b', + namespace: 'ns', + canonicalName: 'ns:skill-b', + type: 'skill', + filePath: '/b.md', + source: 'plugin:ns', + description: undefined, + metadata: {}, + }, + { + name: 'agent-x', + namespace: 'ns', + canonicalName: 'ns:agent-x', + type: 'agent', + filePath: '/x.md', + source: 'plugin:ns', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + assert.strictEqual(manifest.summary.total, 3); + assert.strictEqual(manifest.summary.skills, 2); + assert.strictEqual(manifest.summary.agents, 1); + }); + + it('should list unique namespaces in summary', () => { + const components: NamespacedComponent[] = [ + { + name: 'skill', + namespace: 'plugin-a', + canonicalName: 'plugin-a:skill', + type: 'skill', + filePath: '/a.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }, + { + name: 'skill', + namespace: 'plugin-b', + canonicalName: 'plugin-b:skill', + type: 'skill', + filePath: '/b.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }, + { + name: 'skill', + namespace: 'plugin-a', // Duplicate namespace + canonicalName: 'plugin-a:skill-2', + type: 'skill', + filePath: '/a2.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + // Should have unique, sorted namespaces + assert.deepStrictEqual(manifest.summary.namespaces, ['plugin-a', 'plugin-b']); + }); + + it('should handle flat (non-namespaced) components', () => { + const components: NamespacedComponent[] = [ + { + name: 'flat-skill', + namespace: undefined, + canonicalName: 'flat-skill', + type: 'skill', + filePath: '/flat.md', + source: 'user', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + assert.strictEqual(manifest.entries.length, 1); + assert.strictEqual(manifest.entries[0]!.namespace, undefined); + assert.strictEqual(manifest.entries[0]!.canonicalName, 'flat-skill'); + assert.deepStrictEqual(manifest.summary.namespaces, []); + }); + + it('should be serializable to JSON', () => { + const components: NamespacedComponent[] = [ + { + name: 'skill', + namespace: 'plugin', + canonicalName: 'plugin:skill', + type: 'skill', + filePath: '/skill.md', + source: 'plugin:plugin', + description: 'A skill', + metadata: { pluginVersion: '1.0.0' }, + }, + ]; + + const manifest = importer.getImportManifest(components); + + // Should be JSON serializable without errors + const json = JSON.stringify(manifest); + const parsed = JSON.parse(json); + + assert.strictEqual(parsed.schemaVersion, '1.0'); + assert.strictEqual(parsed.entries[0].canonicalName, 'plugin:skill'); + }); + }); + + describe('Full Pipeline: discover → select → validate → manifest', () => { + it('should execute full pipeline with mock components', () => { + // Stage 1: Discover (empty in this case) + const discovery = importer.discover([]); + assert.strictEqual(discovery.summary.totalComponents, 0); + + // Stage 2: Select all (empty) + const selected = importer.selectComponents(() => true); + assert.strictEqual(selected.length, 0); + + // Stage 3: Validate + const validation = importer.validateImport(selected); + assert.strictEqual(validation.canProceed, true); + + // Stage 4: Manifest + const manifest = importer.getImportManifest(selected); + assert.strictEqual(manifest.summary.total, 0); + }); + + it('should preserve canonical names through full pipeline (R013)', () => { + // Start with discovery + importer.discover([]); + + // Create mock components as if they were discovered + const components: NamespacedComponent[] = [ + { + name: 'code-review', + namespace: 'my-plugin', + canonicalName: 'my-plugin:code-review', + type: 'skill', + filePath: '/plugins/my-plugin/skills/code-review/SKILL.md', + source: 'plugin:my-plugin', + description: 'Reviews code', + metadata: { pluginVersion: '1.0.0' }, + }, + { + name: 'architect', + namespace: 'my-plugin', + canonicalName: 'my-plugin:architect', + type: 'agent', + filePath: '/plugins/my-plugin/agents/architect/AGENT.md', + source: 'plugin:my-plugin', + description: 'Designs architecture', + metadata: { pluginVersion: '1.0.0' }, + }, + ]; + + // Stage 3: Validate (no collisions) + const validation = importer.validateImport(components); + assert.strictEqual(validation.canProceed, true); + + // Stage 4: Manifest + const manifest = importer.getImportManifest(components); + + // Verify canonical names preserved + assert.strictEqual(manifest.entries.length, 2); + assert.strictEqual(manifest.entries[0]!.canonicalName, 'my-plugin:code-review'); + assert.strictEqual(manifest.entries[1]!.canonicalName, 'my-plugin:architect'); + + // Verify round-trip identity + const skill = manifest.entries.find((e) => e.type === 'skill'); + assert.ok(skill !== undefined); + assert.strictEqual(skill!.canonicalName, 'my-plugin:code-review'); + assert.strictEqual(skill!.name, 'code-review'); + assert.strictEqual(skill!.namespace, 'my-plugin'); + }); + + it('should block import on canonical collision', () => { + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'skill', + namespace: 'ns', + canonicalName: 'ns:skill', + type: 'skill', + filePath: '/first.md', + source: 'first', + description: undefined, + metadata: {}, + }, + { + name: 'skill', + namespace: 'ns', + canonicalName: 'ns:skill', // Collision! + type: 'skill', + filePath: '/second.md', + source: 'second', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(components); + + // Should block + assert.strictEqual(validation.canProceed, false); + + // Diagnostic should explain why + assert.strictEqual(validation.summary.errors, 1); + assert.ok(validation.diagnostics[0]!.remediation.length > 0); + }); + + it('should allow import with warnings (shorthand overlap)', () => { + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'review', + namespace: 'plugin-a', + canonicalName: 'plugin-a:review', + type: 'skill', + filePath: '/a.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }, + { + name: 'review', + namespace: 'plugin-b', + canonicalName: 'plugin-b:review', + type: 'skill', + filePath: '/b.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(components); + + // Should NOT block (warning only) + assert.strictEqual(validation.canProceed, true); + assert.strictEqual(validation.summary.warnings, 1); + + // Manifest should still work + const manifest = importer.getImportManifest(components); + assert.strictEqual(manifest.entries.length, 2); + }); + }); + + describe('Inspection methods', () => { + it('should return null for getRegistry() before discover()', () => { + assert.strictEqual(importer.getRegistry(), null); + }); + + it('should return registry after discover()', () => { + importer.discover([]); + assert.ok(importer.getRegistry() !== null); + }); + + it('should return empty array for getDiscoveredPlugins() before discover()', () => { + const plugins = importer.getDiscoveredPlugins(); + assert.deepStrictEqual(plugins, []); + }); + + it('should return null for getLastValidation() before validateImport()', () => { + assert.strictEqual(importer.getLastValidation(), null); + }); + + it('should return last validation after validateImport()', () => { + importer.discover([]); + importer.validateImport([]); + assert.ok(importer.getLastValidation() !== null); + }); + + it('should return null for getLastDiscovery() before discover()', () => { + assert.strictEqual(importer.getLastDiscovery(), null); + }); + + it('should return last discovery after discover()', () => { + importer.discover([]); + assert.ok(importer.getLastDiscovery() !== null); + }); + }); + + describe('Diagnostic structure verification', () => { + it('should provide actionable remediation in diagnostics', () => { + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/first.md', + source: 'first', + description: undefined, + metadata: {}, + }, + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/second.md', + source: 'second', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(components); + const diag = validation.diagnostics[0]; + + assert.ok(diag !== undefined); + assert.ok(diag!.remediation.length > 0); + assert.ok(diag!.remediation.includes('ns:dup')); + }); + + it('should include file paths in collision diagnostic', () => { + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/first/dup.md', + source: 'first', + description: undefined, + metadata: {}, + }, + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/second/dup.md', + source: 'second', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(components); + const diag = validation.diagnostics[0]; + + assert.ok(diag!.filePaths.includes('/first/dup.md')); + assert.ok(diag!.filePaths.includes('/second/dup.md')); + }); + }); +}); + +describe('R012: Discover / select / import flow', () => { + it('should support staged discovery → selection → validation → manifest', () => { + const importer = new PluginImporter(); + + // Stage 1: Discover + const discovery = importer.discover([]); + assert.ok(discovery.registry !== undefined); + + // Stage 2: Select + const selected = importer.selectComponents(() => true); + assert.ok(Array.isArray(selected)); + + // Stage 3: Validate + const validation = importer.validateImport(selected); + assert.ok(typeof validation.canProceed === 'boolean'); + assert.ok(Array.isArray(validation.diagnostics)); + + // Stage 4: Manifest + const manifest = importer.getImportManifest(selected); + assert.ok(manifest.schemaVersion === '1.0'); + assert.ok(Array.isArray(manifest.entries)); + }); + + it('should allow independent testing of each stage', () => { + const importer = new PluginImporter(); + + // Each stage can be tested independently + importer.discover([]); + + // Selection can be called multiple times with different filters + const all = importer.selectComponents(() => true); + const skills = importer.selectComponents((c) => c.type === 'skill'); + const agents = importer.selectComponents((c) => c.type === 'agent'); + + // All should work without error + assert.ok(true); + + // Validation can be called with any component set + const validation1 = importer.validateImport(all); + const validation2 = importer.validateImport(skills); + const validation3 = importer.validateImport(agents); + + assert.ok(validation1.canProceed === true); + assert.ok(validation2.canProceed === true); + assert.ok(validation3.canProceed === true); + + // Manifest can be generated for any component set + const manifest1 = importer.getImportManifest(all); + const manifest2 = importer.getImportManifest(skills); + const manifest3 = importer.getImportManifest(agents); + + assert.ok(manifest1.schemaVersion === '1.0'); + assert.ok(manifest2.schemaVersion === '1.0'); + assert.ok(manifest3.schemaVersion === '1.0'); + }); +}); + +describe('R013: Canonical name preservation', () => { + it('should preserve plugin:component format in manifest entries', () => { + const importer = new PluginImporter(); + + const components: NamespacedComponent[] = [ + { + name: 'my-skill', + namespace: 'my-plugin', + canonicalName: 'my-plugin:my-skill', + type: 'skill', + filePath: '/skill.md', + source: 'plugin:my-plugin', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + assert.strictEqual(manifest.entries[0]!.canonicalName, 'my-plugin:my-skill'); + }); + + it('should preserve flat names for non-namespaced components', () => { + const importer = new PluginImporter(); + + const components: NamespacedComponent[] = [ + { + name: 'flat-skill', + namespace: undefined, + canonicalName: 'flat-skill', + type: 'skill', + filePath: '/skill.md', + source: 'user', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + assert.strictEqual(manifest.entries[0]!.canonicalName, 'flat-skill'); + assert.strictEqual(manifest.entries[0]!.namespace, undefined); + }); + + it('should support round-trip identity (name + namespace → canonical)', () => { + const importer = new PluginImporter(); + + const components: NamespacedComponent[] = [ + { + name: 'component', + namespace: 'namespace', + canonicalName: 'namespace:component', + type: 'skill', + filePath: '/path', + source: 'source', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + const entry = manifest.entries[0]!; + + // Round-trip: namespace:name should equal canonicalName + const reconstructed = entry.namespace + ? `${entry.namespace}:${entry.name}` + : entry.name; + + assert.strictEqual(reconstructed, entry.canonicalName); + assert.strictEqual(reconstructed, 'namespace:component'); + }); +}); + +// ============================================================================ +// T02: Command Flow Integration Tests +// ============================================================================ + +describe('T02: Command flow integration', () => { + describe('Marketplace detection', () => { + it('should categorize plugin roots into marketplaces vs flat paths', () => { + // Import the helper function (we'll need to export it for testing) + // For now, test the logic indirectly + const importer = new PluginImporter(); + + // Non-existent paths should still work + const result = importer.discover(['/nonexistent/marketplace']); + + // Should not crash and return valid structure + assert.ok(result.summary.marketplacesProcessed === 1); + }); + + it('should handle empty marketplace paths gracefully', () => { + const importer = new PluginImporter(); + + const result = importer.discover([]); + + assert.strictEqual(result.summary.marketplacesProcessed, 0); + assert.strictEqual(result.summary.totalPlugins, 0); + assert.strictEqual(result.summary.totalComponents, 0); + }); + }); + + describe('Component selection flow', () => { + it('should support filtering by plugin namespace', () => { + const importer = new PluginImporter(); + importer.discover([]); + + // Create mock components as if discovered + const components: NamespacedComponent[] = [ + { + name: 'skill-a', + namespace: 'plugin-x', + canonicalName: 'plugin-x:skill-a', + type: 'skill', + filePath: '/x/skill-a.md', + source: 'plugin:plugin-x', + description: undefined, + metadata: {}, + }, + { + name: 'skill-b', + namespace: 'plugin-y', + canonicalName: 'plugin-y:skill-b', + type: 'skill', + filePath: '/y/skill-b.md', + source: 'plugin:plugin-y', + description: undefined, + metadata: {}, + }, + ]; + + // Validate should work with any component set + const validation = importer.validateImport(components); + assert.strictEqual(validation.canProceed, true); + + // Manifest should preserve namespace info + const manifest = importer.getImportManifest(components); + assert.strictEqual(manifest.entries.length, 2); + assert.strictEqual(manifest.summary.namespaces.length, 2); + assert.ok(manifest.summary.namespaces.includes('plugin-x')); + assert.ok(manifest.summary.namespaces.includes('plugin-y')); + }); + + it('should support filtering by component type', () => { + const importer = new PluginImporter(); + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'skill-a', + namespace: 'plugin', + canonicalName: 'plugin:skill-a', + type: 'skill', + filePath: '/skill-a.md', + source: 'plugin:plugin', + description: undefined, + metadata: {}, + }, + { + name: 'agent-x', + namespace: 'plugin', + canonicalName: 'plugin:agent-x', + type: 'agent', + filePath: '/agent-x.md', + source: 'plugin:plugin', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + assert.strictEqual(manifest.summary.skills, 1); + assert.strictEqual(manifest.summary.agents, 1); + }); + }); + + describe('Pre-import diagnostics gating', () => { + it('should block import on canonical collision (error)', () => { + const importer = new PluginImporter(); + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'skill', + namespace: 'ns', + canonicalName: 'ns:skill', + type: 'skill', + filePath: '/first.md', + source: 'first', + description: undefined, + metadata: {}, + }, + { + name: 'skill', + namespace: 'ns', + canonicalName: 'ns:skill', // Collision + type: 'skill', + filePath: '/second.md', + source: 'second', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(components); + + // Should block - error severity + assert.strictEqual(validation.canProceed, false); + assert.strictEqual(validation.summary.errors, 1); + }); + + it('should allow import with shorthand overlap (warning)', () => { + const importer = new PluginImporter(); + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'review', + namespace: 'plugin-a', + canonicalName: 'plugin-a:review', + type: 'skill', + filePath: '/a/review.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }, + { + name: 'review', + namespace: 'plugin-b', + canonicalName: 'plugin-b:review', + type: 'skill', + filePath: '/b/review.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(components); + + // Should NOT block - warning only + assert.strictEqual(validation.canProceed, true); + assert.strictEqual(validation.summary.warnings, 1); + assert.strictEqual(validation.summary.errors, 0); + }); + + it('should provide actionable diagnostics for blocking errors', () => { + const importer = new PluginImporter(); + importer.discover([]); + + const components: NamespacedComponent[] = [ + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/first.md', + source: 'first', + description: undefined, + metadata: {}, + }, + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/second.md', + source: 'second', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(components); + + // Should have diagnostic with remediation + assert.strictEqual(validation.diagnostics.length, 1); + assert.ok(validation.diagnostics[0]!.remediation.length > 0); + assert.ok(validation.diagnostics[0]!.remediation.includes('ns:dup')); + }); + }); + + describe('Config persistence with canonical names', () => { + it('should preserve canonical names in manifest for persistence', () => { + const importer = new PluginImporter(); + + const components: NamespacedComponent[] = [ + { + name: 'code-review', + namespace: 'my-plugin', + canonicalName: 'my-plugin:code-review', + type: 'skill', + filePath: '/plugins/my-plugin/skills/code-review/SKILL.md', + source: 'plugin:my-plugin', + description: 'Reviews code', + metadata: { pluginVersion: '1.0.0' }, + }, + { + name: 'architect', + namespace: 'my-plugin', + canonicalName: 'my-plugin:architect', + type: 'agent', + filePath: '/plugins/my-plugin/agents/architect/AGENT.md', + source: 'plugin:my-plugin', + description: 'Designs architecture', + metadata: { pluginVersion: '1.0.0' }, + }, + ]; + + const manifest = importer.getImportManifest(components); + + // Verify canonical names preserved + assert.strictEqual(manifest.entries.length, 2); + assert.strictEqual(manifest.entries[0]!.canonicalName, 'my-plugin:code-review'); + assert.strictEqual(manifest.entries[1]!.canonicalName, 'my-plugin:architect'); + + // Verify manifest is JSON-serializable for config persistence + const json = JSON.stringify(manifest); + const parsed = JSON.parse(json); + assert.strictEqual(parsed.entries[0].canonicalName, 'my-plugin:code-review'); + }); + + it('should include file paths for settings persistence', () => { + const importer = new PluginImporter(); + + const components: NamespacedComponent[] = [ + { + name: 'skill', + namespace: 'plugin', + canonicalName: 'plugin:skill', + type: 'skill', + filePath: '/absolute/path/to/skill.md', + source: 'plugin:plugin', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + assert.strictEqual(manifest.entries[0]!.filePath, '/absolute/path/to/skill.md'); + }); + + it('should separate skills and agents for settings routing', () => { + const importer = new PluginImporter(); + + const components: NamespacedComponent[] = [ + { + name: 'skill-1', + namespace: 'p', + canonicalName: 'p:skill-1', + type: 'skill', + filePath: '/s1.md', + source: 'plugin:p', + description: undefined, + metadata: {}, + }, + { + name: 'skill-2', + namespace: 'p', + canonicalName: 'p:skill-2', + type: 'skill', + filePath: '/s2.md', + source: 'plugin:p', + description: undefined, + metadata: {}, + }, + { + name: 'agent-1', + namespace: 'p', + canonicalName: 'p:agent-1', + type: 'agent', + filePath: '/a1.md', + source: 'plugin:p', + description: undefined, + metadata: {}, + }, + ]; + + const manifest = importer.getImportManifest(components); + + const skills = manifest.entries.filter(e => e.type === 'skill'); + const agents = manifest.entries.filter(e => e.type === 'agent'); + + assert.strictEqual(skills.length, 2); + assert.strictEqual(agents.length, 1); + }); + }); + + describe('End-to-end command flow simulation', () => { + it('should execute full pipeline: discover → select → validate → manifest', () => { + const importer = new PluginImporter(); + + // Stage 1: Discover (empty in this test) + const discovery = importer.discover([]); + assert.strictEqual(discovery.summary.totalComponents, 0); + + // Stage 2: Simulate user selection (mock components) + const selected: NamespacedComponent[] = [ + { + name: 'code-review', + namespace: 'my-plugin', + canonicalName: 'my-plugin:code-review', + type: 'skill', + filePath: '/plugins/my-plugin/skills/code-review/SKILL.md', + source: 'plugin:my-plugin', + description: 'Reviews code', + metadata: { pluginVersion: '1.0.0' }, + }, + ]; + + // Stage 3: Validate + const validation = importer.validateImport(selected); + assert.strictEqual(validation.canProceed, true); + + // Stage 4: Generate manifest + const manifest = importer.getImportManifest(selected); + assert.strictEqual(manifest.entries.length, 1); + assert.strictEqual(manifest.entries[0]!.canonicalName, 'my-plugin:code-review'); + }); + + it('should block on validation failure before persistence', () => { + const importer = new PluginImporter(); + importer.discover([]); + + const selected: NamespacedComponent[] = [ + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/first.md', + source: 'first', + description: undefined, + metadata: {}, + }, + { + name: 'dup', + namespace: 'ns', + canonicalName: 'ns:dup', + type: 'skill', + filePath: '/second.md', + source: 'second', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(selected); + + // Simulate command flow logic: should NOT proceed to persistence + if (validation.canProceed) { + // This should NOT be reached + assert.fail('Should not proceed to persistence with errors'); + } else { + // Correct: blocked before persistence + assert.strictEqual(validation.summary.errors, 1); + } + }); + + it('should allow proceeding after user confirms warnings', () => { + const importer = new PluginImporter(); + importer.discover([]); + + const selected: NamespacedComponent[] = [ + { + name: 'review', + namespace: 'plugin-a', + canonicalName: 'plugin-a:review', + type: 'skill', + filePath: '/a/review.md', + source: 'plugin:plugin-a', + description: undefined, + metadata: {}, + }, + { + name: 'review', + namespace: 'plugin-b', + canonicalName: 'plugin-b:review', + type: 'skill', + filePath: '/b/review.md', + source: 'plugin:plugin-b', + description: undefined, + metadata: {}, + }, + ]; + + const validation = importer.validateImport(selected); + + // Warnings should NOT block + assert.strictEqual(validation.canProceed, true); + assert.strictEqual(validation.summary.warnings, 1); + + // Simulate user confirmation and proceed to manifest + const manifest = importer.getImportManifest(selected); + assert.strictEqual(manifest.entries.length, 2); + }); + }); +}); diff --git a/src/resources/extensions/universal-config/discovery.ts b/src/resources/extensions/universal-config/discovery.ts index 12768111b..39516ecd0 100644 --- a/src/resources/extensions/universal-config/discovery.ts +++ b/src/resources/extensions/universal-config/discovery.ts @@ -58,6 +58,8 @@ export async function discoverAllConfigs( 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 claudeSkills = allItems.filter((i) => i.type === "claude-skill").length; + const claudePlugins = allItems.filter((i) => i.type === "claude-plugin").length; const toolsWithConfig = toolResults.filter((r) => r.items.length > 0).length; return { @@ -68,6 +70,8 @@ export async function discoverAllConfigs( rules, contextFiles, settings, + claudeSkills, + claudePlugins, totalItems: allItems.length, toolsScanned: TOOLS.length, toolsWithConfig, diff --git a/src/resources/extensions/universal-config/format.ts b/src/resources/extensions/universal-config/format.ts index be6b69a54..515706e98 100644 --- a/src/resources/extensions/universal-config/format.ts +++ b/src/resources/extensions/universal-config/format.ts @@ -23,7 +23,7 @@ export function formatDiscoveryForTool(result: DiscoveryResult): string { 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(`Found: ${summary.mcpServers} MCP server(s), ${summary.rules} rule(s), ${summary.contextFiles} context file(s), ${summary.settings} settings file(s), ${summary.claudeSkills} Claude skill(s), ${summary.claudePlugins} Claude plugin(s)`); lines.push(""); for (const toolResult of result.tools) { @@ -70,9 +70,16 @@ export function formatDiscoveryForTool(result: DiscoveryResult): string { 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(` - ${item.source.path} (${item.source.level})`); + } + } + + if (byType["claude-plugin"]?.length) { + lines.push(` Claude Plugins (${byType["claude-plugin"].length}):`); + for (const item of byType["claude-plugin"]) { + if (item.type !== "claude-plugin") continue; + const label = item.packageName ? `${item.name} [${item.packageName}]` : item.name; + lines.push(` - ${label} (${item.source.level}) ${item.path}`); } } @@ -111,6 +118,8 @@ export function formatDiscoveryForCommand(result: DiscoveryResult): string[] { lines.push(` Rules: ${summary.rules}`); lines.push(` Context: ${summary.contextFiles}`); lines.push(` Settings: ${summary.settings}`); + lines.push(` Claude skills: ${summary.claudeSkills}`); + lines.push(` Claude plugins: ${summary.claudePlugins}`); lines.push(""); for (const toolResult of result.tools) { @@ -122,6 +131,8 @@ export function formatDiscoveryForCommand(result: DiscoveryResult): string[] { 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`); + if (counts["claude-skill"]) parts.push(`${counts["claude-skill"]} Claude skills`); + if (counts["claude-plugin"]) parts.push(`${counts["claude-plugin"]} Claude plugins`); lines.push(` ${toolResult.tool.name}: ${parts.join(", ")}`); @@ -131,6 +142,18 @@ export function formatDiscoveryForCommand(result: DiscoveryResult): string[] { if (server.type !== "mcp-server") continue; lines.push(` MCP: ${server.name} (${server.source.level})`); } + + const claudeSkills = toolResult.items.filter((i) => i.type === "claude-skill"); + for (const skill of claudeSkills) { + if (skill.type !== "claude-skill") continue; + lines.push(` Skill: ${skill.name} (${skill.source.level})`); + } + + const claudePlugins = toolResult.items.filter((i) => i.type === "claude-plugin"); + for (const plugin of claudePlugins) { + if (plugin.type !== "claude-plugin") continue; + lines.push(` Plugin: ${plugin.name} (${plugin.source.level})`); + } } if (result.warnings.length > 0) { diff --git a/src/resources/extensions/universal-config/index.ts b/src/resources/extensions/universal-config/index.ts index 9201c5e88..bd0492858 100644 --- a/src/resources/extensions/universal-config/index.ts +++ b/src/resources/extensions/universal-config/index.ts @@ -31,13 +31,13 @@ export default function universalConfig(pi: ExtensionAPI) { 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, " + + "Discovers MCP servers, rules, context files, settings, Claude skills, and Claude plugins 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.", + promptSnippet: "Discover existing AI tool configs (MCP servers, rules, context files, Claude skills/plugins) 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.", + "Results include MCP servers that could be reused, rules/instructions that could be adapted, context files from other tools, and Claude skills/plugins that could be imported.", ], parameters: Type.Object({ tool: Type.Optional( @@ -83,6 +83,8 @@ export default function universalConfig(pi: ExtensionAPI) { 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, + claudeSkills: allItems.filter((i) => i.type === "claude-skill").length, + claudePlugins: allItems.filter((i) => i.type === "claude-plugin").length, totalItems: allItems.length, toolsWithConfig: filtered.filter((t) => t.items.length > 0).length, }, diff --git a/src/resources/extensions/universal-config/scanners.ts b/src/resources/extensions/universal-config/scanners.ts index 874a7b0f4..3688a3ff8 100644 --- a/src/resources/extensions/universal-config/scanners.ts +++ b/src/resources/extensions/universal-config/scanners.ts @@ -8,6 +8,7 @@ */ import { readFile, readdir, stat } from "node:fs/promises"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, basename, resolve } from "node:path"; import { homedir } from "node:os"; import type { @@ -29,6 +30,30 @@ function source(tool: ToolInfo, path: string, level: ConfigLevel): ConfigSource return { tool: tool.id, toolName: tool.name, path, level }; } +function walkDirectories(root: string, visit: (dir: string, depth: number) => void, maxDepth = 4): void { + const skip = new Set([".git", "node_modules", ".worktrees", "dist", "build", "cache", ".cache"]); + + function walk(dir: string, depth: number) { + visit(dir, depth); + if (depth >= maxDepth) return; + + let entries: Array<{ name: string; isDirectory: () => boolean }> = []; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + walk(join(dir, entry.name), depth + 1); + } + } + + walk(root, 0); +} + async function readTextFile(path: string): Promise { try { return await readFile(path, "utf8"); @@ -208,6 +233,44 @@ async function scanClaude(projectRoot: string, home: string, tool: ToolInfo): Pr } } + // Claude skills: ~/.claude/skills/**/SKILL.md + const userSkillsRoot = join(home, ".claude/skills"); + if (existsSync(userSkillsRoot)) { + walkDirectories(userSkillsRoot, (dir) => { + const skillFile = join(dir, "SKILL.md"); + if (!existsSync(skillFile)) return; + items.push({ + type: "claude-skill", + name: basename(dir), + path: dir, + source: source(tool, skillFile, "user"), + }); + }, 5); + } + + // Claude plugins: ~/.claude/plugins/**/package.json + const userPluginsRoot = join(home, ".claude/plugins"); + if (existsSync(userPluginsRoot)) { + walkDirectories(userPluginsRoot, (dir) => { + const packageJsonPath = join(dir, "package.json"); + if (!existsSync(packageJsonPath)) return; + let packageName: string | undefined; + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string }; + packageName = pkg.name; + } catch { + packageName = undefined; + } + items.push({ + type: "claude-plugin", + name: packageName || basename(dir), + packageName, + path: dir, + source: source(tool, packageJsonPath, "user"), + }); + }, 4); + } + // User-level settings: ~/.claude/settings.json const userSettings = join(home, ".claude/settings.json"); const settingsContent = await readTextFile(userSettings); diff --git a/src/resources/extensions/universal-config/tests/discovery.test.ts b/src/resources/extensions/universal-config/tests/discovery.test.ts index 7d3706881..dd1eef96f 100644 --- a/src/resources/extensions/universal-config/tests/discovery.test.ts +++ b/src/resources/extensions/universal-config/tests/discovery.test.ts @@ -41,6 +41,8 @@ describe("discoverAllConfigs", () => { assert.equal(result.summary.totalItems, 0); assert.equal(result.summary.toolsScanned, 8); assert.equal(result.summary.toolsWithConfig, 0); + assert.equal(result.summary.claudeSkills, 0); + assert.equal(result.summary.claudePlugins, 0); assert.ok(result.durationMs >= 0); } finally { cleanup(); @@ -53,6 +55,8 @@ describe("discoverAllConfigs", () => { writeJson(join(testHome, ".claude.json"), { mcpServers: { "claude-mcp": { command: "node", args: ["server.js"] } }, }); + writeText(join(testHome, ".claude/skills/test-skill/SKILL.md"), "# Test skill"); + writeJson(join(testHome, ".claude/plugins/test-plugin/package.json"), { name: "test-plugin" }); writeText(join(testRoot, ".cursorrules"), "Use semicolons."); writeText(join(testRoot, ".github/copilot-instructions.md"), "Be helpful."); @@ -61,7 +65,9 @@ describe("discoverAllConfigs", () => { assert.equal(result.summary.mcpServers, 1); assert.equal(result.summary.rules, 1); assert.equal(result.summary.contextFiles, 1); - assert.equal(result.allItems.length, 3); + assert.equal(result.summary.claudeSkills, 1); + assert.equal(result.summary.claudePlugins, 1); + assert.equal(result.allItems.length, 5); } finally { cleanup(); } @@ -103,6 +109,8 @@ describe("discoverAllConfigs", () => { assert.equal(result.summary.rules, 2); assert.equal(result.summary.contextFiles, 1); assert.equal(result.summary.settings, 1); + assert.equal(result.summary.claudeSkills, 0); + assert.equal(result.summary.claudePlugins, 0); assert.equal(result.summary.totalItems, 5); } finally { cleanup(); diff --git a/src/resources/extensions/universal-config/tests/format.test.ts b/src/resources/extensions/universal-config/tests/format.test.ts index 0d0f48370..aecd29f12 100644 --- a/src/resources/extensions/universal-config/tests/format.test.ts +++ b/src/resources/extensions/universal-config/tests/format.test.ts @@ -16,6 +16,8 @@ const emptyResult: DiscoveryResult = { rules: 0, contextFiles: 0, settings: 0, + claudeSkills: 0, + claudePlugins: 0, totalItems: 0, toolsScanned: 8, toolsWithConfig: 0, @@ -38,11 +40,17 @@ const populatedResult: DiscoveryResult = { 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" }, + type: "claude-skill", + name: "cursor-mdc-editor", + path: "/home/user/.claude/skills/cursor-mdc-editor", + source: { tool: "claude", toolName: "Claude Code", path: "/home/user/.claude/skills/cursor-mdc-editor/SKILL.md", level: "user" }, + }, + { + type: "claude-plugin", + name: "context-mode", + packageName: "context-mode", + path: "/home/user/.claude/plugins/marketplaces/context-mode", + source: { tool: "claude", toolName: "Claude Code", path: "/home/user/.claude/plugins/marketplaces/context-mode/package.json", level: "user" }, }, ], warnings: [], @@ -66,7 +74,9 @@ const populatedResult: DiscoveryResult = { rules: 1, contextFiles: 1, settings: 0, - totalItems: 3, + claudeSkills: 1, + claudePlugins: 1, + totalItems: 5, toolsScanned: 8, toolsWithConfig: 2, }, @@ -86,10 +96,14 @@ describe("formatDiscoveryForTool", () => { 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("1 Claude skill(s)")); + assert.ok(text.includes("1 Claude plugin(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")); + assert.ok(text.includes("cursor-mdc-editor")); + assert.ok(text.includes("context-mode")); }); }); @@ -107,5 +121,7 @@ describe("formatDiscoveryForCommand", () => { assert.ok(text.includes("2 of 8")); assert.ok(text.includes("Cursor")); assert.ok(text.includes("MCP: test-mcp")); + assert.ok(text.includes("Skill: cursor-mdc-editor")); + assert.ok(text.includes("Plugin: context-mode")); }); }); diff --git a/src/resources/extensions/universal-config/tests/scanners.test.ts b/src/resources/extensions/universal-config/tests/scanners.test.ts index f31c8452a..c63e7c4b6 100644 --- a/src/resources/extensions/universal-config/tests/scanners.test.ts +++ b/src/resources/extensions/universal-config/tests/scanners.test.ts @@ -107,6 +107,24 @@ describe("Claude Code scanner", () => { } }); + test("discovers Claude Code skills and plugins", async () => { + const { testRoot, testHome, cleanup } = makeTempDirs(); + try { + writeText(join(testHome, ".claude/skills/test-skill/SKILL.md"), "# test skill"); + writeJson(join(testHome, ".claude/plugins/test-plugin/package.json"), { name: "test-plugin" }); + + const { items } = await SCANNERS.claude(testRoot, testHome, getTool("claude")); + const skills = items.filter((i) => i.type === "claude-skill"); + const plugins = items.filter((i) => i.type === "claude-plugin"); + assert.equal(skills.length, 1); + assert.equal(plugins.length, 1); + if (skills[0]?.type === "claude-skill") assert.equal(skills[0].name, "test-skill"); + if (plugins[0]?.type === "claude-plugin") assert.equal(plugins[0].name, "test-plugin"); + } finally { + cleanup(); + } + }); + test("discovers settings.json", async () => { const { testRoot, testHome, cleanup } = makeTempDirs(); try { diff --git a/src/resources/extensions/universal-config/types.ts b/src/resources/extensions/universal-config/types.ts index 3f3eeae37..1c353d590 100644 --- a/src/resources/extensions/universal-config/types.ts +++ b/src/resources/extensions/universal-config/types.ts @@ -80,11 +80,28 @@ export interface DiscoveredSettings { source: ConfigSource; } +export interface DiscoveredClaudeSkill { + type: "claude-skill"; + name: string; + path: string; + source: ConfigSource; +} + +export interface DiscoveredClaudePlugin { + type: "claude-plugin"; + name: string; + path: string; + packageName?: string; + source: ConfigSource; +} + export type DiscoveredItem = | DiscoveredMCPServer | DiscoveredRule | DiscoveredContextFile - | DiscoveredSettings; + | DiscoveredSettings + | DiscoveredClaudeSkill + | DiscoveredClaudePlugin; // ── Discovery result ────────────────────────────────────────────────────────── @@ -105,6 +122,8 @@ export interface DiscoveryResult { rules: number; contextFiles: number; settings: number; + claudeSkills: number; + claudePlugins: number; totalItems: number; toolsScanned: number; toolsWithConfig: number; diff --git a/src/tests/marketplace-discovery.test.ts b/src/tests/marketplace-discovery.test.ts new file mode 100644 index 000000000..455c7a189 --- /dev/null +++ b/src/tests/marketplace-discovery.test.ts @@ -0,0 +1,405 @@ +/** + * Marketplace Discovery Contract Tests + * + * Contract tests that exercise discoverMarketplace against real marketplace repos + * (../claude_skills and ../claude-plugins-official). These tests validate: + * - R001: marketplace parsing + * - R002: path resolution + * - R003: manifest inspection + * + * Tests run against real data, not synthetic fixtures. + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { + parseMarketplaceJson, + inspectPlugin, + discoverMarketplace, + resolvePluginRoot +} from '../resources/extensions/gsd/marketplace-discovery.js'; + +// Resolve paths to the external marketplace repos +// Tests run from src/tests/, so we need to go up to gsd-2, then into ../claude_skills +const REPOS_BASE = path.resolve(import.meta.dirname, '../../..'); +const CLAUDE_SKILLS_PATH = path.join(REPOS_BASE, 'claude_skills'); +const CLAUDE_PLUGINS_OFFICIAL_PATH = path.join(REPOS_BASE, 'claude-plugins-official'); + +describe('Marketplace Discovery Contract Tests', () => { + describe('claude_skills marketplace (jamie-style)', () => { + it('should discover at least 15 plugins', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + + assert.strictEqual(result.status, 'ok', `Expected ok status, got error: ${result.error}`); + assert.ok(result.plugins.length >= 15, + `Expected at least 15 plugins, found ${result.plugins.length}`); + }); + + it('should detect jamie-style format', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + + assert.strictEqual(result.pluginFormat, 'jamie-style'); + }); + + it('should verify python3-development has skills and agents', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + const pythonPlugin = result.plugins.find(p => p.name === 'python3-development'); + + assert.ok(pythonPlugin, 'python3-development plugin should exist'); + assert.strictEqual(pythonPlugin.status, 'ok', + `Plugin should have ok status, got error: ${pythonPlugin.error}`); + + // Verify skills inventory + assert.ok(pythonPlugin.inventory.skills.length > 0, + `python3-development should have skills, found: ${pythonPlugin.inventory.skills.length}`); + assert.ok(pythonPlugin.inventory.skills.length >= 10, + `python3-development should have at least 10 skills, found ${pythonPlugin.inventory.skills.length}`); + + // Verify agents inventory + assert.ok(pythonPlugin.inventory.agents.length > 0, + `python3-development should have agents, found: ${pythonPlugin.inventory.agents.length}`); + assert.ok(pythonPlugin.inventory.agents.length >= 5, + `python3-development should have at least 5 agents, found ${pythonPlugin.inventory.agents.length}`); + }); + + it('should verify all resolved paths exist on disk', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + + // Filter plugins with resolved paths (local plugins, not external) + const localPlugins = result.plugins.filter(p => p.resolvedPath !== null); + + assert.ok(localPlugins.length > 0, 'Should have at least one local plugin'); + + for (const plugin of localPlugins) { + assert.ok(fs.existsSync(plugin.resolvedPath!), + `Plugin ${plugin.name} resolved path should exist: ${plugin.resolvedPath}`); + } + }); + + it('should preserve canonical names for known plugins', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + const knownPluginNames = [ + 'python3-development', + 'bash-development', + 'gitlab-skill', + 'commitlint', + 'conventional-commits', + 'fastmcp-creator' + ]; + + for (const expectedName of knownPluginNames) { + const plugin = result.plugins.find(p => p.name === expectedName); + assert.ok(plugin, `Plugin ${expectedName} should exist`); + assert.strictEqual(plugin.canonicalName, expectedName, + `Canonical name should match for ${expectedName}`); + } + }); + + it('should have consistent summary counts', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + + assert.strictEqual(result.summary.total, result.plugins.length, + 'Total count should match plugins array length'); + assert.strictEqual(result.summary.ok, + result.plugins.filter(p => p.status === 'ok').length, + 'Ok count should match plugins with ok status'); + assert.strictEqual(result.summary.error, + result.plugins.filter(p => p.status === 'error').length, + 'Error count should match plugins with error status'); + }); + }); + + describe('claude-plugins-official marketplace (official-style)', () => { + it('should discover at least 10 plugins', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + assert.strictEqual(result.status, 'ok', `Expected ok status, got error: ${result.error}`); + assert.ok(result.plugins.length >= 10, + `Expected at least 10 plugins, found ${result.plugins.length}`); + }); + + it('should detect official-style format', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + assert.strictEqual(result.pluginFormat, 'official-style'); + }); + + it('should extract LSP servers from inline marketplace metadata', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + // TypeScript LSP plugin should have lspServers from marketplace.json + const tsPlugin = result.plugins.find(p => p.name === 'typescript-lsp'); + assert.ok(tsPlugin, 'typescript-lsp plugin should exist'); + assert.ok(Object.keys(tsPlugin.inventory.lspServers).length > 0, + 'typescript-lsp should have LSP servers from inline metadata'); + assert.ok('typescript' in tsPlugin.inventory.lspServers, + 'typescript-lsp should have typescript LSP server'); + + // Verify LSP server config structure + const tsLspConfig = tsPlugin.inventory.lspServers.typescript as { command?: string }; + assert.strictEqual(tsLspConfig.command, 'typescript-language-server', + 'TypeScript LSP should use typescript-language-server command'); + }); + + it('should have description from inline metadata', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + const tsPlugin = result.plugins.find(p => p.name === 'typescript-lsp'); + assert.ok(tsPlugin, 'typescript-lsp plugin should exist'); + assert.ok(tsPlugin.description, 'typescript-lsp should have description'); + assert.ok(tsPlugin.description.includes('TypeScript'), + 'Description should mention TypeScript'); + }); + + it('should handle external plugins (URL sources) correctly', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + // Find plugins with URL sources (external) + const externalPlugins = result.plugins.filter(p => p.resolvedPath === null); + + assert.ok(externalPlugins.length > 0, + 'Should have at least one external plugin with null resolvedPath'); + + // External plugins should still have ok status (they're valid, just not local) + const atlassian = externalPlugins.find(p => p.name === 'atlassian'); + assert.ok(atlassian, 'atlassian plugin should exist as external'); + assert.strictEqual(atlassian.status, 'ok', + 'External plugins should have ok status'); + }); + + it('should preserve canonical names for known official plugins', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + const knownPluginNames = [ + 'typescript-lsp', + 'pyright-lsp', + 'gopls-lsp', + 'rust-analyzer-lsp', + 'feature-dev', + 'pr-review-toolkit' + ]; + + for (const expectedName of knownPluginNames) { + const plugin = result.plugins.find(p => p.name === expectedName); + assert.ok(plugin, `Plugin ${expectedName} should exist in official marketplace`); + assert.strictEqual(plugin.canonicalName, expectedName, + `Canonical name should match for ${expectedName}`); + } + }); + + it('should extract multiple LSP server types', () => { + const result = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + // Check that multiple LSP plugins have their servers extracted + const lspPlugins = [ + { name: 'pyright-lsp', server: 'pyright' }, + { name: 'gopls-lsp', server: 'gopls' }, + { name: 'rust-analyzer-lsp', server: 'rust-analyzer' }, + { name: 'clangd-lsp', server: 'clangd' } + ]; + + for (const { name, server } of lspPlugins) { + const plugin = result.plugins.find(p => p.name === name); + assert.ok(plugin, `${name} plugin should exist`); + assert.ok(server in plugin.inventory.lspServers, + `${name} should have ${server} LSP server`); + } + }); + }); + + describe('Error handling', () => { + it('should return structured error for non-existent repo path', () => { + const result = discoverMarketplace('/tmp/nonexistent-marketplace-' + Date.now()); + + assert.strictEqual(result.status, 'error'); + assert.ok(result.error, 'Error message should be present'); + assert.ok(result.error.includes('not found'), + `Error should mention 'not found', got: ${result.error}`); + assert.deepStrictEqual(result.plugins, []); + assert.strictEqual(result.summary.total, 0); + assert.strictEqual(result.summary.ok, 0); + assert.strictEqual(result.summary.error, 0); + }); + + it('should return error for directory without marketplace.json', () => { + // Create a temp directory without marketplace.json + const tmpDir = '/tmp/test-no-marketplace-' + Date.now(); + fs.mkdirSync(tmpDir, { recursive: true }); + + try { + const result = discoverMarketplace(tmpDir); + + assert.strictEqual(result.status, 'error'); + assert.ok(result.error, 'Error message should be present'); + assert.ok(result.error.includes('not found'), + `Error should mention 'not found', got: ${result.error}`); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it('should return error for malformed marketplace.json', () => { + const tmpDir = '/tmp/test-malformed-marketplace-' + Date.now(); + fs.mkdirSync(tmpDir + '/.claude-plugin', { recursive: true }); + fs.writeFileSync(tmpDir + '/.claude-plugin/marketplace.json', '{ this is not valid json }'); + + try { + const result = discoverMarketplace(tmpDir); + + assert.strictEqual(result.status, 'error'); + assert.ok(result.error, 'Error message should be present'); + assert.ok(result.error.includes('Failed to parse'), + `Error should mention 'Failed to parse', got: ${result.error}`); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it('should return error for marketplace.json missing required fields', () => { + const tmpDir = '/tmp/test-invalid-marketplace-' + Date.now(); + fs.mkdirSync(tmpDir + '/.claude-plugin', { recursive: true }); + // Valid JSON but missing required 'name' and 'plugins' fields + fs.writeFileSync(tmpDir + '/.claude-plugin/marketplace.json', JSON.stringify({ description: 'test' })); + + try { + const parseResult = parseMarketplaceJson(tmpDir); + + assert.strictEqual(parseResult.success, false); + if (!parseResult.success) { + assert.ok(parseResult.error.includes('missing'), + `Error should mention missing field, got: ${parseResult.error}`); + } + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it('should handle missing plugin directory gracefully', () => { + const tmpDir = '/tmp/test-missing-plugin-' + Date.now(); + fs.mkdirSync(tmpDir + '/.claude-plugin', { recursive: true }); + fs.writeFileSync(tmpDir + '/.claude-plugin/marketplace.json', JSON.stringify({ + name: 'test-marketplace', + plugins: [ + { name: 'missing-plugin', source: './plugins/nonexistent' } + ] + })); + + try { + const result = discoverMarketplace(tmpDir); + + // Marketplace should parse ok, but the missing plugin should have error status + assert.strictEqual(result.status, 'error'); // Because one plugin has error + + const missingPlugin = result.plugins.find(p => p.name === 'missing-plugin'); + assert.ok(missingPlugin, 'Missing plugin should be in results'); + assert.strictEqual(missingPlugin.status, 'error'); + assert.ok(missingPlugin.error, 'Missing plugin should have error message'); + assert.ok(missingPlugin.error.includes('not found'), + `Error should mention 'not found', got: ${missingPlugin.error}`); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + }); + + describe('Component inventory accuracy', () => { + it('should accurately count skills in python3-development', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + const pythonPlugin = result.plugins.find(p => p.name === 'python3-development'); + + assert.ok(pythonPlugin, 'python3-development should exist'); + + // Verify by directly counting the skills directory + const skillsDir = path.join(pythonPlugin.resolvedPath!, 'skills'); + if (fs.existsSync(skillsDir)) { + const actualSkills = fs.readdirSync(skillsDir) + .filter(item => { + const itemPath = path.join(skillsDir, item); + return fs.statSync(itemPath).isDirectory() || item.endsWith('.md'); + }); + + // Allow for some variance due to filtering differences + assert.ok(Math.abs(pythonPlugin.inventory.skills.length - actualSkills.length) <= 2, + `Skills count should be close to actual: reported ${pythonPlugin.inventory.skills.length}, actual ${actualSkills.length}`); + } + }); + + it('should discover MCP servers from plugin.json', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + const pythonPlugin = result.plugins.find(p => p.name === 'python3-development'); + + assert.ok(pythonPlugin, 'python3-development should exist'); + assert.ok(Object.keys(pythonPlugin.inventory.mcpServers).length > 0, + 'python3-development should have MCP servers from plugin.json'); + }); + + it('should include commands in inventory when present', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + const pythonPlugin = result.plugins.find(p => p.name === 'python3-development'); + + assert.ok(pythonPlugin, 'python3-development should exist'); + assert.ok(pythonPlugin.inventory.commands.length > 0, + 'python3-development should have commands'); + }); + + it('should detect hooks when present', () => { + const result = discoverMarketplace(CLAUDE_SKILLS_PATH); + + // Find any plugin with hooks + const pluginWithHooks = result.plugins.find(p => + p.inventory.hooks && p.inventory.hooks.length > 0 + ); + + // At least some plugins should have hooks + assert.ok(pluginWithHooks !== undefined, + 'At least one plugin should have hooks'); + }); + }); + + describe('Cross-marketplace consistency', () => { + it('should return consistent type structure for both marketplaces', () => { + const jamie = discoverMarketplace(CLAUDE_SKILLS_PATH); + const official = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + // Both should have the same top-level structure + const requiredKeys = ['status', 'marketplacePath', 'marketplaceName', + 'pluginFormat', 'plugins', 'summary']; + + for (const key of requiredKeys) { + assert.ok(key in jamie, `jamie result should have ${key}`); + assert.ok(key in official, `official result should have ${key}`); + } + + // Both summaries should have same structure + const summaryKeys = ['total', 'ok', 'error']; + for (const key of summaryKeys) { + assert.ok(key in jamie.summary, `jamie summary should have ${key}`); + assert.ok(key in official.summary, `official summary should have ${key}`); + } + }); + + it('should return consistent plugin structure', () => { + const jamie = discoverMarketplace(CLAUDE_SKILLS_PATH); + const official = discoverMarketplace(CLAUDE_PLUGINS_OFFICIAL_PATH); + + const jamiePlugin = jamie.plugins[0]; + const officialPlugin = official.plugins[0]; + + const requiredKeys = ['name', 'canonicalName', 'source', 'resolvedPath', + 'status', 'manifestSource', 'inventory']; + + for (const key of requiredKeys) { + assert.ok(key in jamiePlugin, `jamie plugin should have ${key}`); + assert.ok(key in officialPlugin, `official plugin should have ${key}`); + } + + // Inventory structure should be consistent + const inventoryKeys = ['skills', 'agents', 'commands', 'mcpServers', 'lspServers']; + for (const key of inventoryKeys) { + assert.ok(key in jamiePlugin.inventory, `jamie inventory should have ${key}`); + assert.ok(key in officialPlugin.inventory, `official inventory should have ${key}`); + } + }); + }); +});