feat: import Claude marketplace plugins with namespaced components
This commit is contained in:
parent
e21ebec072
commit
2e3fa903b1
24 changed files with 8142 additions and 18 deletions
640
src/resources/extensions/gsd/claude-import.ts
Normal file
640
src/resources/extensions/gsd/claude-import.ts
Normal 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");
|
||||
}
|
||||
332
src/resources/extensions/gsd/collision-diagnostics.ts
Normal file
332
src/resources/extensions/gsd/collision-diagnostics.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
502
src/resources/extensions/gsd/marketplace-discovery.ts
Normal file
502
src/resources/extensions/gsd/marketplace-discovery.ts
Normal 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
|
||||
};
|
||||
467
src/resources/extensions/gsd/namespaced-registry.ts
Normal file
467
src/resources/extensions/gsd/namespaced-registry.ts
Normal 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;
|
||||
307
src/resources/extensions/gsd/namespaced-resolver.ts
Normal file
307
src/resources/extensions/gsd/namespaced-resolver.ts
Normal 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;
|
||||
410
src/resources/extensions/gsd/plugin-importer.ts
Normal file
410
src/resources/extensions/gsd/plugin-importer.ts
Normal 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;
|
||||
314
src/resources/extensions/gsd/tests/claude-import-tui.test.ts
Normal file
314
src/resources/extensions/gsd/tests/claude-import-tui.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
705
src/resources/extensions/gsd/tests/collision-diagnostics.test.ts
Normal file
705
src/resources/extensions/gsd/tests/collision-diagnostics.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
197
src/resources/extensions/gsd/tests/marketplace-discovery.test.ts
Normal file
197
src/resources/extensions/gsd/tests/marketplace-discovery.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
1027
src/resources/extensions/gsd/tests/namespaced-registry.test.ts
Normal file
1027
src/resources/extensions/gsd/tests/namespaced-registry.test.ts
Normal file
File diff suppressed because it is too large
Load diff
671
src/resources/extensions/gsd/tests/namespaced-resolver.test.ts
Normal file
671
src/resources/extensions/gsd/tests/namespaced-resolver.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
479
src/resources/extensions/gsd/tests/plugin-importer-live.test.ts
Normal file
479
src/resources/extensions/gsd/tests/plugin-importer-live.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
1383
src/resources/extensions/gsd/tests/plugin-importer.test.ts
Normal file
1383
src/resources/extensions/gsd/tests/plugin-importer.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
405
src/tests/marketplace-discovery.test.ts
Normal file
405
src/tests/marketplace-discovery.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue