feat: import Claude marketplace plugins with namespaced components

This commit is contained in:
Jamie McGregor Nelson 2026-03-15 09:03:28 -04:00
parent e21ebec072
commit 2e3fa903b1
24 changed files with 8142 additions and 18 deletions

View file

@ -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<string>();
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<string>();
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<string>();
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<T extends { name: string; path: string; root: string; sourceLabel: string }>(
ctx: ExtensionCommandContext,
title: string,
candidates: T[],
): Promise<T[]> {
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<string | { source: string }> {
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<NamespacedComponent[]> {
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<string, unknown>,
writePrefs: (prefs: Record<string, unknown>) => Promise<void>,
): Promise<void> {
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<string>();
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");
}

View file

@ -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<string, typeof components>();
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 ?? '<unknown>',
aliasedComponent?.filePath ?? '<unknown>',
],
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,
};

View file

@ -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<void> {
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
if (!existsSync(path)) {
await ensurePreferencesFile(path, ctx, scope);
}
const readPrefs = (): Record<string, unknown> => {
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<string, unknown>): Promise<void> => {
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<void> {

View file

@ -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<string, unknown>;
lspServers?: Record<string, unknown>;
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<string, unknown>;
lspServers?: Record<string, unknown>;
// 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<string, unknown>;
lspServers: Record<string, unknown>;
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
};

View file

@ -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<string, NamespacedComponent>();
/** Internal storage: alias -> canonicalName */
private aliasMap = new Map<string, string>();
/** 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<NamespacedComponent, 'canonicalName'>): 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<string, string> {
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<NamespacedComponent, 'canonicalName'>[] {
const components: Omit<NamespacedComponent, 'canonicalName'>[] = [];
// 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 <plugin>/skills/<name>/SKILL.md or <plugin>/skills/<name>.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 = `<external>/${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 = `<external>/${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;

View file

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

View file

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

View file

@ -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<typeof mock.fn>;
notify: ReturnType<typeof mock.fn>;
};
waitForIdle: ReturnType<typeof mock.fn>;
reload: ReturnType<typeof mock.fn>;
};
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<typeof runClaudeImportFlow>[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<string, unknown>;
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<typeof mock.fn>).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<string, unknown> = { 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<string, unknown>) => {
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 });
}
});
});
}
);

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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) {

View file

@ -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,
},

View file

@ -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<string | null> {
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);

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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