diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index bb3603e..a674b0d 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -2,57 +2,91 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import chalk from 'chalk'; -import { command, positional, option, flag, string, optional, restPositionals } from 'cmd-ts'; -import { syncWorkspace, syncUserWorkspace } from '../../core/sync.js'; import { - removeDisabledSkill, + command, + flag, + option, + optional, + positional, + restPositionals, + string, +} from 'cmd-ts'; +import { + CONFIG_DIR, + WORKSPACE_CONFIG_FILE, + getHomeDir, +} from '../../constants.js'; +import { + addMarketplace, + findMarketplace, + listMarketplacePlugins, + updateMarketplace, +} from '../../core/marketplace.js'; +import { + fetchPlugin, + getPluginName, + seedFetchCache, +} from '../../core/plugin.js'; +import { + SkillSearchError, + type SkillSearchItem, + type SkillSearchOptions, + qualifiedName, + searchSkills, +} from '../../core/skill-search.js'; +import { + type DiscoveredSkillEntry, + discoverSkillEntries, + discoverSkillNames, + findSkillByName, + getAllSkillsFromPlugins, +} from '../../core/skills.js'; +import { upsertSyncStateSource } from '../../core/sync-state.js'; +import { syncUserWorkspace, syncWorkspace } from '../../core/sync.js'; +import type { SyncResult } from '../../core/sync.js'; +import { + addUserEnabledSkill, + addUserPlugin, + hasUserPlugin, + isUserConfigPath, + removeUserDisabledSkill, + setUserPluginSkillsMode, + upsertUserGitHubPluginSourceAllowlist, +} from '../../core/user-workspace.js'; +import { addEnabledSkill, addPlugin, hasPlugin, + removeDisabledSkill, resolveGitHubIdentity, setPluginSkillsMode, upsertGitHubPluginSourceAllowlist, } from '../../core/workspace-modify.js'; import { - removeUserDisabledSkill, - addUserEnabledSkill, - isUserConfigPath, - addUserPlugin, - hasUserPlugin, - setUserPluginSkillsMode, - upsertUserGitHubPluginSourceAllowlist, -} from '../../core/user-workspace.js'; -import { getAllSkillsFromPlugins, findSkillByName, discoverSkillNames } from '../../core/skills.js'; -import { isJsonMode, jsonOutput } from '../json-output.js'; + parseMarketplaceManifest, + resolvePluginSourcePath, +} from '../../utils/marketplace-manifest-parser.js'; +import { + formatPluginSource, + isGitHubUrl, + parseGitHubUrl, + stripGitRef, +} from '../../utils/plugin-path.js'; +import { parseSkillMetadata } from '../../validators/skill.js'; +import { + formatSyncHeader, + formatSyncSummary, + formatVerboseSyncLines, +} from '../format-sync.js'; import { buildDescription, conciseSubcommands } from '../help.js'; +import { isJsonMode, jsonOutput } from '../json-output.js'; import { + skillsAddMeta, skillsListMeta, skillsRemoveMeta, - skillsAddMeta, skillsSearchMeta, } from '../metadata/plugin-skills.js'; -import { - searchSkills, - SkillSearchError, - qualifiedName, - type SkillSearchOptions, - type SkillSearchItem, -} from '../../core/skill-search.js'; -import { getHomeDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../constants.js'; -import { isGitHubUrl, parseGitHubUrl, stripGitRef } from '../../utils/plugin-path.js'; -import { fetchPlugin, getPluginName, seedFetchCache } from '../../core/plugin.js'; -import { upsertSyncStateSource } from '../../core/sync-state.js'; -import { parseSkillMetadata } from '../../validators/skill.js'; -import { - addMarketplace, - findMarketplace, - listMarketplacePlugins, - updateMarketplace, -} from '../../core/marketplace.js'; -import { parseMarketplaceManifest, resolvePluginSourcePath } from '../../utils/marketplace-manifest-parser.js'; -import { formatSyncHeader, formatSyncSummary, formatVerboseSyncLines } from '../format-sync.js'; import { removeInstalledSkill } from '../skill-removal.js'; -import type { SyncResult } from '../../core/sync.js'; /** * Check if a directory has a project-level .allagents config @@ -110,7 +144,10 @@ async function recordSourceProvenance(opts: { }); } -export function resolveFetchedSourcePath(source: string, cachePath: string): string { +export function resolveFetchedSourcePath( + source: string, + cachePath: string, +): string { if (!isGitHubUrl(source)) return cachePath; const parsed = parseGitHubUrl(source); return parsed?.subpath ? join(cachePath, parsed.subpath) : cachePath; @@ -143,7 +180,11 @@ function extractInlineRef(spec: string): string | undefined { */ export function resolveSkillFromUrl( skill: string, -): { skill: string; from: string; parsed: ReturnType } | null { +): { + skill: string; + from: string; + parsed: ReturnType; +} | null { if (!isGitHubUrl(skill)) return null; const parsed = parseGitHubUrl(skill); @@ -175,7 +216,10 @@ export async function resolveSkillNameFromRepo( if (!fetchResult.success) return fallbackName; try { - const skillMd = await readFile(join(fetchResult.cachePath, 'SKILL.md'), 'utf-8'); + const skillMd = await readFile( + join(fetchResult.cachePath, 'SKILL.md'), + 'utf-8', + ); const metadata = parseSkillMetadata(skillMd); return metadata?.name ?? fallbackName; } catch { @@ -187,18 +231,41 @@ export async function resolveSkillNameFromRepo( * Group skills by plugin for display */ function groupSkillsByPlugin( - skills: Array<{ name: string; pluginName: string; pluginSource: string; disabled: boolean }>, -): Map }> { - const grouped = new Map }>(); + skills: Array<{ + name: string; + pluginName: string; + pluginSource: string; + disabled: boolean; + skillSubpath?: string; + }>, +): Map< + string, + { + source: string; + skills: Array<{ name: string; subpath: string; disabled: boolean }>; + } +> { + const grouped = new Map< + string, + { + source: string; + skills: Array<{ name: string; subpath: string; disabled: boolean }>; + } + >(); for (const skill of skills) { + const entry = { + name: skill.name, + subpath: skill.skillSubpath ?? skill.name, + disabled: skill.disabled, + }; const existing = grouped.get(skill.pluginName); if (existing) { - existing.skills.push({ name: skill.name, disabled: skill.disabled }); + existing.skills.push(entry); } else { grouped.set(skill.pluginName, { source: skill.pluginSource, - skills: [{ name: skill.name, disabled: skill.disabled }], + skills: [entry], }); } } @@ -230,17 +297,24 @@ const listCmd = command({ const showUser = scope !== 'project'; const showProject = scope === 'project' || (!scope && inProjectDir); - const userSkills = showUser ? await getAllSkillsFromPlugins(getHomeDir()) : []; - const projectSkills = showProject ? await getAllSkillsFromPlugins(cwd) : []; + const userSkills = showUser + ? await getAllSkillsFromPlugins(getHomeDir()) + : []; + const projectSkills = showProject + ? await getAllSkillsFromPlugins(cwd) + : []; // For dedup: if same plugin:skill exists in both, only show in user - const userKeys = new Set(userSkills.map((s) => `${s.pluginName}:${s.name}`)); + const userKeys = new Set( + userSkills.map((s) => `${s.pluginName}:${s.name}`), + ); const dedupedProjectSkills = projectSkills.filter( (s) => !userKeys.has(`${s.pluginName}:${s.name}`), ); if (isJsonMode()) { - const effectiveScope = scope === 'user' ? 'user' : scope === 'project' ? 'project' : 'all'; + const effectiveScope = + scope === 'user' ? 'user' : scope === 'project' ? 'project' : 'all'; const allSkills = [...userSkills, ...dedupedProjectSkills]; jsonOutput({ success: true, @@ -268,11 +342,15 @@ const listCmd = command({ console.log(`\n${chalk.whiteBright('User Skills:')}`); const grouped = groupSkillsByPlugin(userSkills); for (const [pluginName, data] of grouped) { - console.log(`\n${chalk.hex("#89b4fa")(pluginName)} (${data.source}):`); + console.log( + `\n${chalk.hex('#89b4fa')(pluginName)} (${formatPluginSource(data.source)}):`, + ); for (const skill of data.skills) { const icon = skill.disabled ? '\u2717' : '\u2713'; const status = skill.disabled ? ' (disabled)' : ''; - console.log(` ${icon} ${skill.name}${status}`); + const displayName = + skill.subpath !== skill.name ? skill.subpath : skill.name; + console.log(` ${icon} ${displayName}${status}`); } } } @@ -282,11 +360,15 @@ const listCmd = command({ console.log(`\n${chalk.whiteBright('Project Skills:')}`); const grouped = groupSkillsByPlugin(dedupedProjectSkills); for (const [pluginName, data] of grouped) { - console.log(`\n${chalk.hex("#89b4fa")(pluginName)} (${data.source}):`); + console.log( + `\n${chalk.hex('#89b4fa')(pluginName)} (${formatPluginSource(data.source)}):`, + ); for (const skill of data.skills) { const icon = skill.disabled ? '\u2717' : '\u2713'; const status = skill.disabled ? ' (disabled)' : ''; - console.log(` ${icon} ${skill.name}${status}`); + const displayName = + skill.subpath !== skill.name ? skill.subpath : skill.name; + console.log(` ${icon} ${displayName}${status}`); } } } @@ -294,7 +376,11 @@ const listCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill list', error: error.message }); + jsonOutput({ + success: false, + command: 'skill list', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -329,7 +415,8 @@ const removeCmd = command({ }, handler: async ({ skill, scope, plugin }) => { try { - const isUser = scope === 'user' || (!scope && resolveScope(process.cwd()) === 'user'); + const isUser = + scope === 'user' || (!scope && resolveScope(process.cwd()) === 'user'); const workspacePath = isUser ? getHomeDir() : process.cwd(); // Find the skill @@ -337,7 +424,9 @@ const removeCmd = command({ const matches = allSkills.filter((candidate) => candidate.name === skill); if (matches.length === 0) { - const skillNames = [...new Set(allSkills.map((s) => s.name))].join(', '); + const skillNames = [...new Set(allSkills.map((s) => s.name))].join( + ', ', + ); const error = `Skill '${skill}' not found in any installed plugin.\n\nAvailable skills: ${skillNames || 'none'}`; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill remove', error }); @@ -355,7 +444,9 @@ const removeCmd = command({ } if (matches.length > 1) { if (!plugin) { - const pluginList = matches.map((m) => ` - ${m.pluginName} (${m.pluginSource})`).join('\n'); + const pluginList = matches + .map((m) => ` - ${m.pluginName} (${m.pluginSource})`) + .join('\n'); const error = `'${skill}' exists in multiple plugins:\n${pluginList}\n\nUse --plugin to specify: allagents skill remove ${skill} --plugin `; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill remove', error }); @@ -396,7 +487,11 @@ const removeCmd = command({ }); if (!result.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill remove', error: result.error ?? 'Unknown error' }); + jsonOutput({ + success: false, + command: 'skill remove', + error: result.error ?? 'Unknown error', + }); process.exit(1); } console.error(`Error: ${result.error}`); @@ -407,13 +502,19 @@ const removeCmd = command({ if (result.action === 'removed-plugin') { console.log(`\u2713 Removed plugin: ${targetSkill.pluginSource}`); } else if (result.action === 'removed-skill') { - console.log(`\u2713 Removed skill: ${skill} (${targetSkill.pluginName})`); + console.log( + `\u2713 Removed skill: ${skill} (${targetSkill.pluginName})`, + ); } else { - console.log(`\u2713 Disabled skill: ${skill} (${targetSkill.pluginName})`); + console.log( + `\u2713 Disabled skill: ${skill} (${targetSkill.pluginName})`, + ); } } - const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + const syncResult = isUser + ? await syncUserWorkspace() + : await syncWorkspace(workspacePath); if (isJsonMode()) { jsonOutput({ @@ -434,7 +535,11 @@ const removeCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill remove', error: error.message }); + jsonOutput({ + success: false, + command: 'skill remove', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -450,7 +555,11 @@ const removeCmd = command({ // ============================================================================= type InstallSkillResult = - | { success: true; pluginName: string; syncResult: { copied: number; failed: number } } + | { + success: true; + pluginName: string; + syncResult: { copied: number; failed: number }; + } | { success: false; error: string }; /** @@ -468,16 +577,21 @@ type InstallSkillFromSourceDeps = { installSkillDirect?: typeof installSkillDirect; }; -export async function installSkillFromSource(opts: { - skill: string; - from: string; - isUser: boolean; - workspacePath: string; -}, deps: InstallSkillFromSourceDeps = {}): Promise { +export async function installSkillFromSource( + opts: { + skill: string; + from: string; + isUser: boolean; + workspacePath: string; + }, + deps: InstallSkillFromSourceDeps = {}, +): Promise { const { skill, from, isUser, workspacePath } = opts; const fetchPluginFn = deps.fetchPlugin ?? fetchPlugin; - const parseMarketplaceManifestFn = deps.parseMarketplaceManifest ?? parseMarketplaceManifest; - const installSkillViaMarketplaceFn = deps.installSkillViaMarketplace ?? installSkillViaMarketplace; + const parseMarketplaceManifestFn = + deps.parseMarketplaceManifest ?? parseMarketplaceManifest; + const installSkillViaMarketplaceFn = + deps.installSkillViaMarketplace ?? installSkillViaMarketplace; const installSkillDirectFn = deps.installSkillDirect ?? installSkillDirect; if (!isJsonMode()) { @@ -490,7 +604,10 @@ export async function installSkillFromSource(opts: { ...(parsed?.branch && { branch: parsed.branch }), }); if (!fetchResult.success) { - return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` }; + return { + success: false, + error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}`, + }; } const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath); @@ -503,7 +620,13 @@ export async function installSkillFromSource(opts: { } // Not a marketplace — install as a direct plugin - return installSkillDirectFn({ skill, from, isUser, workspacePath, sourcePath }); + return installSkillDirectFn({ + skill, + from, + isUser, + workspacePath, + sourcePath, + }); } /** @@ -530,7 +653,10 @@ async function installSkillViaMarketplace(opts: { if (existingAnyScope) { marketplaceName = existingAnyScope.name; - await updateMarketplace(marketplaceName, isUser ? undefined : workspacePath); + await updateMarketplace( + marketplaceName, + isUser ? undefined : workspacePath, + ); } else { // Register at the target scope const scopeOptions = isUser @@ -551,13 +677,22 @@ async function installSkillViaMarketplace(opts: { } if (!marketplaceName) { - return { success: false, error: `Failed to register marketplace from '${from}'` }; + return { + success: false, + error: `Failed to register marketplace from '${from}'`, + }; } // List plugins in the marketplace and scan each for the requested skill - const mktPlugins = await listMarketplacePlugins(marketplaceName, isUser ? undefined : workspacePath); + const mktPlugins = await listMarketplacePlugins( + marketplaceName, + isUser ? undefined : workspacePath, + ); if (mktPlugins.plugins.length === 0) { - return { success: false, error: `No plugins found in marketplace '${marketplaceName}'.` }; + return { + success: false, + error: `No plugins found in marketplace '${marketplaceName}'.`, + }; } let targetPluginName: string | null = null; @@ -590,13 +725,90 @@ async function installSkillViaMarketplace(opts: { if (!installResult.success) { // Plugin may already be installed — that's fine, we just need to add the skill - if (!installResult.error?.includes('already exists') && !installResult.error?.includes('duplicates existing')) { - return { success: false, error: `Failed to install plugin '${pluginSpec}': ${installResult.error ?? 'Unknown error'}` }; + if ( + !installResult.error?.includes('already exists') && + !installResult.error?.includes('duplicates existing') + ) { + return { + success: false, + error: `Failed to install plugin '${pluginSpec}': ${installResult.error ?? 'Unknown error'}`, + }; } } // Set allowlist or add skill to existing allowlist - return applySkillAllowlist({ skill, pluginName: targetPluginName, isUser, workspacePath }); + return applySkillAllowlist({ + skill, + pluginName: targetPluginName, + isUser, + workspacePath, + }); +} + +/** + * Decide whether the positional argument to `skill add` is a plugin source + * (npx-skills shape) or a skill name (legacy shape). + * + * The positional is interpreted as a source only when it looks like a GitHub + * spec AND the user supplied an explicit selector (--skill, --list, --all). + * Without a selector we fall through to the legacy resolveSkillFromUrl path, + * which preserves the deep-URL form `skill add `. + */ +export function classifySkillAddPositional( + positional: string | undefined, + skillFlag: string | undefined, + list: boolean, + all: boolean, +): + | { shape: 'none' } + | { shape: 'skill-name' } + | { shape: 'source'; skills: string[] } { + if (!positional) return { shape: 'none' }; + const skills = skillFlag + ? skillFlag + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + const hasSelector = skills.length > 0 || list || all; + if (isGitHubUrl(positional) && hasSelector) { + return { shape: 'source', skills }; + } + return { shape: 'skill-name' }; +} + +/** + * Resolve a user-supplied skill spec (bare leaf name or `parent/leaf`) against + * the available entries in a plugin. Returns the canonical identifier to + * persist in the allowlist — bare leaf name when unambiguous, qualified + * subpath when the leaf is shared by multiple nested skills. + */ +function resolveSkillSpec( + spec: string, + available: DiscoveredSkillEntry[], +): { canonical: string; matched: DiscoveredSkillEntry } | { error: string } { + const exact = available.find((e) => e.subpath === spec); + if (exact) { + const canonical = exact.subpath === exact.name ? exact.name : exact.subpath; + return { canonical, matched: exact }; + } + + const nameMatches = available.filter((e) => e.name === spec); + if (nameMatches.length === 1) { + const m = nameMatches[0] as DiscoveredSkillEntry; + return { canonical: m.name, matched: m }; + } + if (nameMatches.length > 1) { + const paths = nameMatches.map((m) => m.subpath).join(', '); + return { + error: `Multiple skills named '${spec}': ${paths}. Use the qualified form (e.g., --skill ${nameMatches[0]?.subpath}) to choose one.`, + }; + } + + const available_names = available.map((e) => e.subpath).join(', '); + return { + error: `Skill '${spec}' not found.\n\nAvailable skills: ${available_names || 'none'}`, + }; } /** @@ -612,22 +824,32 @@ async function installSkillDirect(opts: { const { skill, from, isUser, workspacePath, sourcePath } = opts; // Verify the skill exists in the cached plugin before installing - const availableSkills = await discoverSkillNames(sourcePath); - if (!availableSkills.includes(skill)) { + const availableEntries = await discoverSkillEntries(sourcePath); + const resolved = resolveSkillSpec(skill, availableEntries); + if ('error' in resolved) { return { success: false, - error: `Skill '${skill}' not found in plugin '${from}'.\n\nAvailable skills: ${availableSkills.join(', ') || 'none'}\n\nTip: run \`allagents skill list\` to see all installed skills.`, + error: `${resolved.error}\n\nTip: run \`allagents skill add --list --from ${from}\` to see all available skills.`, }; } + const canonicalSkill = resolved.canonical; if (isGitHubUrl(from)) { - const existingEnabledSkills = await getEnabledSkillsForGitHubSource(from, workspacePath); + const existingEnabledSkills = await getEnabledSkillsForGitHubSource( + from, + workspacePath, + ); const desiredSkills = [...existingEnabledSkills]; - if (!desiredSkills.includes(skill)) desiredSkills.push(skill); + if (!desiredSkills.includes(canonicalSkill)) + desiredSkills.push(canonicalSkill); const updateResult = isUser ? await upsertUserGitHubPluginSourceAllowlist(from, desiredSkills) - : await upsertGitHubPluginSourceAllowlist(from, desiredSkills, workspacePath); + : await upsertGitHubPluginSourceAllowlist( + from, + desiredSkills, + workspacePath, + ); if (!updateResult.success) { return { @@ -637,8 +859,10 @@ async function installSkillDirect(opts: { } return finishSkillEnable({ - skill, - pluginName: extractPrimaryPluginName(updateResult.normalizedPlugin ?? from), + skill: canonicalSkill, + pluginName: extractPrimaryPluginName( + updateResult.normalizedPlugin ?? from, + ), isUser, workspacePath, }); @@ -649,8 +873,14 @@ async function installSkillDirect(opts: { : await addPlugin(from, workspacePath); if (!installResult.success) { - if (!installResult.error?.includes('already exists') && !installResult.error?.includes('duplicates existing')) { - return { success: false, error: `Failed to install plugin '${from}': ${installResult.error ?? 'Unknown error'}` }; + if ( + !installResult.error?.includes('already exists') && + !installResult.error?.includes('duplicates existing') + ) { + return { + success: false, + error: `Failed to install plugin '${from}': ${installResult.error ?? 'Unknown error'}`, + }; } if (!isJsonMode()) { console.log('Plugin already installed.'); @@ -658,7 +888,12 @@ async function installSkillDirect(opts: { } const pluginName = getPluginName(sourcePath); - return applySkillAllowlist({ skill, pluginName, isUser, workspacePath }); + return applySkillAllowlist({ + skill: canonicalSkill, + pluginName, + isUser, + workspacePath, + }); } function extractPrimaryPluginName(source: string): string { @@ -718,17 +953,28 @@ async function applySkillAllowlist(opts: { if (!addResult.success) { // Already in allowlist = already enabled if (!addResult.error?.includes('already enabled')) { - return { success: false, error: `Failed to enable skill: ${addResult.error ?? 'Unknown error'}` }; + return { + success: false, + error: `Failed to enable skill: ${addResult.error ?? 'Unknown error'}`, + }; } } } else { // No allowlist yet — create one with just this skill const setModeResult = isUser ? await setUserPluginSkillsMode(pluginName, 'allowlist', [skill]) - : await setPluginSkillsMode(pluginName, 'allowlist', [skill], workspacePath); + : await setPluginSkillsMode( + pluginName, + 'allowlist', + [skill], + workspacePath, + ); if (!setModeResult.success) { - return { success: false, error: `Failed to configure skill allowlist: ${setModeResult.error ?? 'Unknown error'}` }; + return { + success: false, + error: `Failed to configure skill allowlist: ${setModeResult.error ?? 'Unknown error'}`, + }; } } @@ -747,7 +993,9 @@ async function finishSkillEnable(opts: { console.log(`\u2713 Enabled skill: ${skill} (${pluginName})`); } - const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + const syncResult = isUser + ? await syncUserWorkspace() + : await syncWorkspace(workspacePath); if (!syncResult.success) { return { success: false, error: 'Sync failed' }; } @@ -768,24 +1016,12 @@ async function finishSkillEnable(opts: { interface DiscoveredSkill { name: string; + /** Path-qualified identifier when the skill is nested below `skills/`. */ + subpath: string; description: string; pluginName?: string; } -/** - * Resolve the SKILL.md path for a skill name in a plugin directory. - * Handles standard (skills//), flat (/), and root layouts. - */ -function resolveSkillMdPath(pluginPath: string, skillName: string): string { - const standardPath = join(pluginPath, 'skills', skillName, 'SKILL.md'); - if (existsSync(standardPath)) return standardPath; - - const flatPath = join(pluginPath, skillName, 'SKILL.md'); - if (existsSync(flatPath)) return flatPath; - - return join(pluginPath, 'SKILL.md'); -} - /** * Read SKILL.md frontmatter for each discovered skill in a plugin directory. */ @@ -793,11 +1029,11 @@ export async function discoverSkillsWithMetadata( pluginPath: string, pluginName?: string, ): Promise { - const names = await discoverSkillNames(pluginPath); + const entries = await discoverSkillEntries(pluginPath); const results: DiscoveredSkill[] = []; - for (const name of names) { - const skillMdPath = resolveSkillMdPath(pluginPath, name); + for (const entry of entries) { + const skillMdPath = join(entry.skillPath, 'SKILL.md'); let description = ''; try { const content = await readFile(skillMdPath, 'utf-8'); @@ -806,7 +1042,12 @@ export async function discoverSkillsWithMetadata( } catch { // Leave description empty } - results.push({ name, description, ...(pluginName && { pluginName }) }); + results.push({ + name: entry.name, + subpath: entry.subpath, + description, + ...(pluginName && { pluginName }), + }); } return results; @@ -816,7 +1057,9 @@ export async function discoverSkillsWithMetadata( * Discover all skills available at a --from source. Handles both direct plugins * and marketplaces (in which case skills from all marketplace plugins are returned). */ -async function discoverSkillsFromSource(from: string): Promise< +async function discoverSkillsFromSource( + from: string, +): Promise< | { success: true; skills: DiscoveredSkill[]; isMarketplace: boolean } | { success: false; error: string } > { @@ -825,7 +1068,10 @@ async function discoverSkillsFromSource(from: string): Promise< ...(parsed?.branch && { branch: parsed.branch }), }); if (!fetchResult.success) { - return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` }; + return { + success: false, + error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}`, + }; } const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath); @@ -856,7 +1102,11 @@ async function installAllSkillsFromSource(opts: { isUser: boolean; workspacePath: string; }): Promise< - | { success: true; installed: Array<{ pluginName: string; skills: string[] }>; syncResult: SyncResult } + | { + success: true; + installed: Array<{ pluginName: string; skills: string[] }>; + syncResult: SyncResult; + } | { success: false; error: string } > { const { from, isUser, workspacePath } = opts; @@ -870,14 +1120,22 @@ async function installAllSkillsFromSource(opts: { ...(parsed?.branch && { branch: parsed.branch }), }); if (!fetchResult.success) { - return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` }; + return { + success: false, + error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}`, + }; } const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath); const manifestResult = await parseMarketplaceManifest(sourcePath); if (manifestResult.success) { - return installAllViaMarketplace({ from, isUser, workspacePath, cachedPath: fetchResult.cachePath }); + return installAllViaMarketplace({ + from, + isUser, + workspacePath, + cachedPath: fetchResult.cachePath, + }); } // Direct plugin install — enable every discovered skill @@ -887,7 +1145,10 @@ async function installAllSkillsFromSource(opts: { } if (isGitHubUrl(from)) { - const existingEnabledSkills = await getEnabledSkillsForGitHubSource(from, workspacePath); + const existingEnabledSkills = await getEnabledSkillsForGitHubSource( + from, + workspacePath, + ); const desiredSkills = [...existingEnabledSkills]; for (const skillName of skillNames) { if (!desiredSkills.includes(skillName)) desiredSkills.push(skillName); @@ -895,7 +1156,11 @@ async function installAllSkillsFromSource(opts: { const updateResult = isUser ? await upsertUserGitHubPluginSourceAllowlist(from, desiredSkills) - : await upsertGitHubPluginSourceAllowlist(from, desiredSkills, workspacePath); + : await upsertGitHubPluginSourceAllowlist( + from, + desiredSkills, + workspacePath, + ); if (!updateResult.success) { return { @@ -904,13 +1169,19 @@ async function installAllSkillsFromSource(opts: { }; } - const pluginName = extractPrimaryPluginName(updateResult.normalizedPlugin ?? from); + const pluginName = extractPrimaryPluginName( + updateResult.normalizedPlugin ?? from, + ); if (!isJsonMode()) { - console.log(`✓ Enabled ${skillNames.length} skill(s) from ${pluginName}: ${skillNames.join(', ')}`); + console.log( + `✓ Enabled ${skillNames.length} skill(s) from ${pluginName}: ${skillNames.join(', ')}`, + ); } - const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + const syncResult = isUser + ? await syncUserWorkspace() + : await syncWorkspace(workspacePath); if (!syncResult.success) { return { success: false, error: 'Sync failed' }; } @@ -922,10 +1193,18 @@ async function installAllSkillsFromSource(opts: { }; } - const installResult = isUser ? await addUserPlugin(from) : await addPlugin(from, workspacePath); + const installResult = isUser + ? await addUserPlugin(from) + : await addPlugin(from, workspacePath); if (!installResult.success) { - if (!installResult.error?.includes('already exists') && !installResult.error?.includes('duplicates existing')) { - return { success: false, error: `Failed to install plugin '${from}': ${installResult.error ?? 'Unknown error'}` }; + if ( + !installResult.error?.includes('already exists') && + !installResult.error?.includes('duplicates existing') + ) { + return { + success: false, + error: `Failed to install plugin '${from}': ${installResult.error ?? 'Unknown error'}`, + }; } if (!isJsonMode()) { console.log('Plugin already installed.'); @@ -936,17 +1215,29 @@ async function installAllSkillsFromSource(opts: { const setModeResult = isUser ? await setUserPluginSkillsMode(pluginName, 'allowlist', skillNames) - : await setPluginSkillsMode(pluginName, 'allowlist', skillNames, workspacePath); + : await setPluginSkillsMode( + pluginName, + 'allowlist', + skillNames, + workspacePath, + ); if (!setModeResult.success) { - return { success: false, error: `Failed to configure skill allowlist: ${setModeResult.error ?? 'Unknown error'}` }; + return { + success: false, + error: `Failed to configure skill allowlist: ${setModeResult.error ?? 'Unknown error'}`, + }; } if (!isJsonMode()) { - console.log(`✓ Enabled ${skillNames.length} skill(s) from ${pluginName}: ${skillNames.join(', ')}`); + console.log( + `✓ Enabled ${skillNames.length} skill(s) from ${pluginName}: ${skillNames.join(', ')}`, + ); } - const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + const syncResult = isUser + ? await syncUserWorkspace() + : await syncWorkspace(workspacePath); if (!syncResult.success) { return { success: false, error: 'Sync failed' }; } @@ -967,7 +1258,11 @@ async function installAllViaMarketplace(opts: { workspacePath: string; cachedPath?: string; }): Promise< - | { success: true; installed: Array<{ pluginName: string; skills: string[] }>; syncResult: SyncResult } + | { + success: true; + installed: Array<{ pluginName: string; skills: string[] }>; + syncResult: SyncResult; + } | { success: false; error: string } > { const { from, isUser, workspacePath, cachedPath } = opts; @@ -983,7 +1278,10 @@ async function installAllViaMarketplace(opts: { if (existingAnyScope) { marketplaceName = existingAnyScope.name; - await updateMarketplace(marketplaceName, isUser ? undefined : workspacePath); + await updateMarketplace( + marketplaceName, + isUser ? undefined : workspacePath, + ); } else { // Seed the fetch cache so any fetchPlugin calls for individual plugins // within the marketplace reuse the already-fetched content. @@ -1007,12 +1305,21 @@ async function installAllViaMarketplace(opts: { } if (!marketplaceName) { - return { success: false, error: `Failed to register marketplace from '${from}'` }; + return { + success: false, + error: `Failed to register marketplace from '${from}'`, + }; } - const mktPlugins = await listMarketplacePlugins(marketplaceName, isUser ? undefined : workspacePath); + const mktPlugins = await listMarketplacePlugins( + marketplaceName, + isUser ? undefined : workspacePath, + ); if (mktPlugins.plugins.length === 0) { - return { success: false, error: `No plugins found in marketplace '${marketplaceName}'.` }; + return { + success: false, + error: `No plugins found in marketplace '${marketplaceName}'.`, + }; } const installed: Array<{ pluginName: string; skills: string[] }> = []; @@ -1030,32 +1337,53 @@ async function installAllViaMarketplace(opts: { : await addPlugin(pluginSpec, workspacePath); if (!installResult.success) { - if (!installResult.error?.includes('already exists') && !installResult.error?.includes('duplicates existing')) { - return { success: false, error: `Failed to install plugin '${pluginSpec}': ${installResult.error ?? 'Unknown error'}` }; + if ( + !installResult.error?.includes('already exists') && + !installResult.error?.includes('duplicates existing') + ) { + return { + success: false, + error: `Failed to install plugin '${pluginSpec}': ${installResult.error ?? 'Unknown error'}`, + }; } } const setModeResult = isUser ? await setUserPluginSkillsMode(mktPlugin.name, 'allowlist', skillNames) - : await setPluginSkillsMode(mktPlugin.name, 'allowlist', skillNames, workspacePath); + : await setPluginSkillsMode( + mktPlugin.name, + 'allowlist', + skillNames, + workspacePath, + ); if (!setModeResult.success) { - return { success: false, error: `Failed to configure skill allowlist for '${mktPlugin.name}': ${setModeResult.error ?? 'Unknown error'}` }; + return { + success: false, + error: `Failed to configure skill allowlist for '${mktPlugin.name}': ${setModeResult.error ?? 'Unknown error'}`, + }; } installed.push({ pluginName: mktPlugin.name, skills: skillNames }); } if (installed.length === 0) { - return { success: false, error: `No skills found across plugins in marketplace '${marketplaceName}'.` }; + return { + success: false, + error: `No skills found across plugins in marketplace '${marketplaceName}'.`, + }; } if (!isJsonMode()) { const total = installed.reduce((sum, i) => sum + i.skills.length, 0); - console.log(`✓ Enabled ${total} skill(s) across ${installed.length} plugin(s)`); + console.log( + `✓ Enabled ${total} skill(s) across ${installed.length} plugin(s)`, + ); } - const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + const syncResult = isUser + ? await syncUserWorkspace() + : await syncWorkspace(workspacePath); if (!syncResult.success) { return { success: false, error: 'Sync failed' }; } @@ -1075,7 +1403,10 @@ const addCmd = command({ name: 'add', description: buildDescription(skillsAddMeta), args: { - skill: positional({ type: optional(string), displayName: 'skill' }), + skill: positional({ + type: optional(string), + displayName: 'skill-or-source', + }), scope: option({ type: optional(string), long: 'scope', @@ -1092,12 +1423,20 @@ const addCmd = command({ type: optional(string), long: 'from', short: 'f', - description: 'Plugin source to install if the skill is not already available', + description: + 'Plugin source to install if the skill is not already available', + }), + skillFlag: option({ + type: optional(string), + long: 'skill', + description: + 'Comma-separated skill names to install when the positional argument is a plugin source (e.g., owner/repo --skill foo,bar)', }), pin: option({ type: optional(string), long: 'pin', - description: 'Pin the plugin to a specific Git ref (tag, branch, or SHA). Mutually exclusive with inline @ref in --from.', + description: + 'Pin the plugin to a specific Git ref (tag, branch, or SHA). Mutually exclusive with inline @ref in --from.', }), list: flag({ long: 'list', @@ -1109,8 +1448,53 @@ const addCmd = command({ description: 'Install every skill from --from', }), }, - handler: async ({ skill: skillArg, scope, plugin, from: fromArg, pin, list, all }) => { + handler: async ({ + skill: skillArg, + scope, + plugin, + from: fromArg, + skillFlag, + pin, + list, + all, + }) => { try { + // npx-skills shape: positional is the source, --skill/--list/--all picks + // what to install. Triggered only when the positional looks like a GitHub + // source AND a selector is provided, so the legacy deep-URL form + // (positional with implicit subpath selector) keeps working. + const classified = classifySkillAddPositional( + skillArg, + skillFlag, + list, + all, + ); + let skillsFromFlag: string[] = []; + if (classified.shape === 'source') { + if (fromArg) { + const error = + 'Cannot use --from when the positional argument is already a plugin source.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + fromArg = skillArg; + skillArg = undefined; + skillsFromFlag = classified.skills; + } else if (skillFlag) { + const error = + '--skill requires the positional argument to be a plugin source (e.g., `skill add owner/repo --skill foo`). To install a known skill, pass the skill name as the positional.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + // Resolve --pin together with inline @ref. Three legal states: // • --pin only → splice into fromArg // • inline @ref → leave fromArg alone, remember pinnedRef @@ -1120,7 +1504,8 @@ const addCmd = command({ if (pin || fromArg) { const inlineRef = fromArg ? extractInlineRef(fromArg) : undefined; if (pin && inlineRef) { - const error = 'Cannot combine inline @version in --from with --pin. Use one or the other.'; + const error = + 'Cannot combine inline @version in --from with --pin. Use one or the other.'; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); process.exit(1); @@ -1149,7 +1534,8 @@ const addCmd = command({ // --list: dry-run discovery, no workspace changes if (list) { if (skillArg) { - const error = 'Cannot combine a skill argument with --list. Use --list alone to discover available skills.'; + const error = + 'Cannot combine a skill argument with --list. Use --list alone to discover available skills.'; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); process.exit(1); @@ -1179,7 +1565,11 @@ const addCmd = command({ const discovered = await discoverSkillsFromSource(fromArg); if (!discovered.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill add', error: discovered.error }); + jsonOutput({ + success: false, + command: 'skill add', + error: discovered.error, + }); process.exit(1); } console.error(`Error: ${discovered.error}`); @@ -1195,6 +1585,7 @@ const addCmd = command({ isMarketplace: discovered.isMarketplace, skills: discovered.skills.map((s) => ({ name: s.name, + ...(s.subpath !== s.name && { path: s.subpath }), description: s.description, ...(s.pluginName && { plugin: s.pluginName }), })), @@ -1210,7 +1601,10 @@ const addCmd = command({ console.log(`\nAvailable skills in ${fromArg}:\n`); for (const s of discovered.skills) { - const label = s.pluginName ? `${s.name} ${chalk.gray(`(${s.pluginName})`)}` : s.name; + const displayName = s.subpath !== s.name ? s.subpath : s.name; + const label = s.pluginName + ? `${displayName} ${chalk.gray(`(${s.pluginName})`)}` + : displayName; console.log(` ${chalk.hex('#89b4fa')(label)}`); if (s.description) console.log(` ${s.description}`); console.log(); @@ -1230,7 +1624,8 @@ const addCmd = command({ process.exit(1); } if (skillArg) { - const error = 'Cannot combine a skill argument with --all. Use --all alone to install every skill.'; + const error = + 'Cannot combine a skill argument with --all. Use --all alone to install every skill.'; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); process.exit(1); @@ -1250,7 +1645,11 @@ const addCmd = command({ if (!installResult.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill add', error: installResult.error }); + jsonOutput({ + success: false, + command: 'skill add', + error: installResult.error, + }); process.exit(1); } console.error(`Error: ${installResult.error}`); @@ -1295,9 +1694,92 @@ const addCmd = command({ return; } + // --skill : install one-or-more named skills from the positional source. + if (skillsFromFlag.length > 0) { + if (!fromArg) { + // Defensive: positional detection above already guarantees fromArg + // when skillsFromFlag is non-empty. + const error = '--skill requires a plugin source positional argument.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + + const isUserSel = scope === 'user'; + const workspacePathSel = isUserSel ? getHomeDir() : process.cwd(); + + const succeeded: Array<{ + skill: string; + plugin: string; + copied: number; + failed: number; + }> = []; + const failures: Array<{ skill: string; error: string }> = []; + + for (const skill of skillsFromFlag) { + const result = await installSkillFromSource({ + skill, + from: fromArg, + isUser: isUserSel, + workspacePath: workspacePathSel, + }); + if (result.success) { + succeeded.push({ + skill, + plugin: result.pluginName, + copied: result.syncResult.copied, + failed: result.syncResult.failed, + }); + } else { + failures.push({ skill, error: result.error }); + } + } + + if (succeeded.length > 0) { + await recordSourceProvenance({ + from: fromArg, + pinnedRef, + workspacePath: workspacePathSel, + isUser: isUserSel, + }); + } + + const allFailed = succeeded.length === 0; + if (isJsonMode()) { + jsonOutput({ + success: !allFailed, + command: 'skill add', + data: { + source: fromArg, + installed: succeeded, + failed: failures, + ...(pinnedRef && { pinnedRef }), + }, + ...(allFailed && { + error: failures.map((f) => `${f.skill}: ${f.error}`).join('; '), + }), + }); + if (allFailed) process.exit(1); + return; + } + + for (const f of failures) { + console.error(`Error installing '${f.skill}': ${f.error}`); + } + if (pinnedRef && succeeded.length > 0) { + console.log(`Pinned to ${pinnedRef}.`); + } + if (allFailed) process.exit(1); + return; + } + // Without --list or --all, skill argument is required. if (!skillArg) { - const error = 'A skill name is required. Use --list to discover available skills or --all to install everything.'; + const error = + 'A skill name is required. Use --list to discover available skills or --all to install everything.'; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); process.exit(1); @@ -1326,7 +1808,11 @@ const addCmd = command({ // For URLs without subpath, try to read skill name from SKILL.md frontmatter if (urlResolved.parsed && !urlResolved.parsed.subpath) { - skill = await resolveSkillNameFromRepo(skill, urlResolved.parsed, urlResolved.skill); + skill = await resolveSkillNameFromRepo( + skill, + urlResolved.parsed, + urlResolved.skill, + ); } else { skill = urlResolved.skill; } @@ -1334,7 +1820,9 @@ const addCmd = command({ // When --from is used (installing a new plugin), default to project scope const hasFromSource = Boolean(from); - const isUser = scope === 'user' || (!scope && !hasFromSource && resolveScope(process.cwd()) === 'user'); + const isUser = + scope === 'user' || + (!scope && !hasFromSource && resolveScope(process.cwd()) === 'user'); const workspacePath = isUser ? getHomeDir() : process.cwd(); // Find the skill @@ -1352,7 +1840,11 @@ const addCmd = command({ if (!installFromResult.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill add', error: installFromResult.error }); + jsonOutput({ + success: false, + command: 'skill add', + error: installFromResult.error, + }); process.exit(1); } console.error(`Error: ${installFromResult.error}`); @@ -1388,7 +1880,9 @@ const addCmd = command({ } const allSkills = await getAllSkillsFromPlugins(workspacePath); - const skillNames = [...new Set(allSkills.map((s) => s.name))].join(', '); + const skillNames = [...new Set(allSkills.map((s) => s.name))].join( + ', ', + ); const error = `Skill '${skill}' not found in any installed plugin.\n\nAvailable skills: ${skillNames || 'none'}`; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); @@ -1406,7 +1900,9 @@ const addCmd = command({ } if (matches.length > 1) { if (!plugin) { - const pluginList = matches.map((m) => ` - ${m.pluginName} (${m.pluginSource})`).join('\n'); + const pluginList = matches + .map((m) => ` - ${m.pluginName} (${m.pluginSource})`) + .join('\n'); const error = `'${skill}' exists in multiple plugins:\n${pluginList}\n\nUse --plugin to specify: allagents skill add ${skill} --plugin `; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); @@ -1441,13 +1937,22 @@ const addCmd = command({ const skillKey = `${targetSkill.pluginName}:${skill}`; - const result = targetSkill.pluginSkillsMode === 'blocklist' - ? isUser ? await removeUserDisabledSkill(skillKey) : await removeDisabledSkill(skillKey, workspacePath) - : isUser ? await addUserEnabledSkill(skillKey) : await addEnabledSkill(skillKey, workspacePath); + const result = + targetSkill.pluginSkillsMode === 'blocklist' + ? isUser + ? await removeUserDisabledSkill(skillKey) + : await removeDisabledSkill(skillKey, workspacePath) + : isUser + ? await addUserEnabledSkill(skillKey) + : await addEnabledSkill(skillKey, workspacePath); if (!result.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill add', error: result.error ?? 'Unknown error' }); + jsonOutput({ + success: false, + command: 'skill add', + error: result.error ?? 'Unknown error', + }); process.exit(1); } console.error(`Error: ${result.error}`); @@ -1455,10 +1960,14 @@ const addCmd = command({ } if (!isJsonMode()) { - console.log(`\u2713 Enabled skill: ${skill} (${targetSkill.pluginName})`); + console.log( + `\u2713 Enabled skill: ${skill} (${targetSkill.pluginName})`, + ); } - const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + const syncResult = isUser + ? await syncUserWorkspace() + : await syncWorkspace(workspacePath); if (isJsonMode()) { jsonOutput({ @@ -1479,7 +1988,11 @@ const addCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill add', error: error.message }); + jsonOutput({ + success: false, + command: 'skill add', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -1494,15 +2007,20 @@ const addCmd = command({ // skill search (GitHub Code Search) // ============================================================================= -export function formatSkillSearchSummary(count: number, query: string, truncated: boolean): string { +export function formatSkillSearchSummary( + count: number, + query: string, + truncated: boolean, +): string { return `Showing ${count} skill${count !== 1 ? 's' : ''} matching "${query}"${truncated ? ' (truncated)' : ''}`; } -export function formatSkillSearchHint(item: Pick): string { - return [ - item.stars > 0 ? `★ ${item.stars}` : '', - item.description ?? '', - ].filter(Boolean).join(' '); +export function formatSkillSearchHint( + item: Pick, +): string { + return [item.stars > 0 ? `★ ${item.stars}` : '', item.description ?? ''] + .filter(Boolean) + .join(' '); } export function collectSelectedSkillSearchRepos( @@ -1523,17 +2041,29 @@ export function collectSelectedSkillSearchRepos( } /** Print results in gh-compatible tabular format: repo, skillName, description, stars. */ -function printSearchResults(items: SkillSearchItem[], query: string, truncated: boolean): void { - console.log(`\n${formatSkillSearchSummary(items.length, query, truncated)}\n`); +function printSearchResults( + items: SkillSearchItem[], + query: string, + truncated: boolean, +): void { + console.log( + `\n${formatSkillSearchSummary(items.length, query, truncated)}\n`, + ); for (const item of items) { const repoCol = item.repo.padEnd(30); const nameCol = qualifiedName(item).padEnd(24); const stars = item.stars > 0 ? chalk.yellow(`★ ${item.stars}`) : ''; const desc = item.description - ? chalk.dim(item.description.length > 60 ? `${item.description.slice(0, 57)}...` : item.description) + ? chalk.dim( + item.description.length > 60 + ? `${item.description.slice(0, 57)}...` + : item.description, + ) : ''; const starsAndDesc = [stars, desc].filter(Boolean).join(' '); - console.log(` ${chalk.cyan(repoCol)} ${chalk.bold(nameCol)} ${starsAndDesc}`); + console.log( + ` ${chalk.cyan(repoCol)} ${chalk.bold(nameCol)} ${starsAndDesc}`, + ); } console.log(''); } @@ -1549,12 +2079,16 @@ async function installFromSearch(repos: string[]): Promise { const installableRepos: string[] = []; for (const repo of repos) { - const isInstalledProject = hasProjectConfig(workspacePath) ? await hasPlugin(repo, workspacePath) : false; + const isInstalledProject = hasProjectConfig(workspacePath) + ? await hasPlugin(repo, workspacePath) + : false; const isInstalledUser = await hasUserPlugin(repo); if (isInstalledProject || isInstalledUser) { const scopeLabel = isInstalledUser ? 'user' : 'project'; - p.log.info(`Plugin ${chalk.bold(repo)} is already installed (${scopeLabel} scope).`); + p.log.info( + `Plugin ${chalk.bold(repo)} is already installed (${scopeLabel} scope).`, + ); continue; } @@ -1576,16 +2110,19 @@ async function installFromSearch(repos: string[]): Promise { if (p.isCancel(scopeChoice)) return false; const s = p.spinner(); - s.start(`Installing ${installableRepos.length === 1 ? 'plugin' : 'plugins'}...`); + s.start( + `Installing ${installableRepos.length === 1 ? 'plugin' : 'plugins'}...`, + ); try { const installedRepos: string[] = []; const failedRepos: Array<{ repo: string; error: string }> = []; for (const repo of installableRepos) { - const result = scopeChoice === 'project' - ? await addPlugin(repo, workspacePath) - : await addUserPlugin(repo); + const result = + scopeChoice === 'project' + ? await addPlugin(repo, workspacePath) + : await addUserPlugin(repo); if (!result.success) { failedRepos.push({ repo, error: result.error ?? 'Unknown error' }); @@ -1604,18 +2141,24 @@ async function installFromSearch(repos: string[]): Promise { } s.message('Syncing...'); - const syncResult = scopeChoice === 'project' - ? await syncWorkspace(workspacePath) - : await syncUserWorkspace(); - - s.stop(installedRepos.length === 1 ? 'Installed and synced' : 'Installed plugins and synced'); + const syncResult = + scopeChoice === 'project' + ? await syncWorkspace(workspacePath) + : await syncUserWorkspace(); + + s.stop( + installedRepos.length === 1 + ? 'Installed and synced' + : 'Installed plugins and synced', + ); for (const { repo, error } of failedRepos) { p.log.error(`${chalk.bold(repo)}: ${error}`); } const lines = formatVerboseSyncLines(syncResult); - const noteLines = installedRepos.length > 1 ? [...installedRepos, '', ...lines] : lines; + const noteLines = + installedRepos.length > 1 ? [...installedRepos, '', ...lines] : lines; if (noteLines.length > 0) { p.note( noteLines.join('\n'), @@ -1711,9 +2254,17 @@ const searchCmd = command({ } // Interactive mode: filter-as-you-type multiselect with install support - const { autocompleteMultiselect, isCancel, log } = await import('@clack/prompts'); + const { autocompleteMultiselect, isCancel, log } = await import( + '@clack/prompts' + ); - log.success(formatSkillSearchSummary(result.items.length, searchQuery, result.truncated)); + log.success( + formatSkillSearchSummary( + result.items.length, + searchQuery, + result.truncated, + ), + ); const options = result.items.map((item) => ({ label: `${qualifiedName(item)} ${chalk.dim(item.repo)}`, @@ -1732,7 +2283,10 @@ const searchCmd = command({ return; } - const reposToInstall = collectSelectedSkillSearchRepos(result.items, selected as string[]); + const reposToInstall = collectSelectedSkillSearchRepos( + result.items, + selected as string[], + ); if (reposToInstall.length === 0) { return; } @@ -1742,7 +2296,11 @@ const searchCmd = command({ if (error instanceof SkillSearchError) { const exitCode = error.kind === 'validation' ? 2 : 1; if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill search', error: error.message }); + jsonOutput({ + success: false, + command: 'skill search', + error: error.message, + }); process.exit(exitCode); } console.error(`Error: ${error.message}`); @@ -1750,7 +2308,11 @@ const searchCmd = command({ } if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'skill search', error: error.message }); + jsonOutput({ + success: false, + command: 'skill search', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index ff73a16..6fa416e 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -1,20 +1,56 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { command, positional, option, flag, string, optional } from 'cmd-ts'; -import { initWorkspace } from '../../core/workspace.js'; -import { syncWorkspace, syncUserWorkspace, mergeSyncResults } from '../../core/sync.js'; -import type { SyncResult } from '../../core/sync.js'; -import { getWorkspaceStatus } from '../../core/status.js'; +import { command, flag, option, optional, positional, string } from 'cmd-ts'; import { pruneOrphanedPlugins } from '../../core/prune.js'; -import { getUserWorkspaceConfig, ensureUserWorkspace } from '../../core/user-workspace.js'; -import { addRepository, removeRepository, listRepositories, detectRemote, updateAgentFiles } from '../../core/workspace-repo.js'; -import { isJsonMode, jsonOutput } from '../json-output.js'; +import { getWorkspaceStatus } from '../../core/status.js'; +import { + mergeSyncResults, + syncUserWorkspace, + syncWorkspace, +} from '../../core/sync.js'; +import type { SyncResult } from '../../core/sync.js'; +import { + ensureUserWorkspace, + getUserWorkspaceConfig, +} from '../../core/user-workspace.js'; +import { + addRepository, + detectRemote, + listRepositories, + removeRepository, + updateAgentFiles, +} from '../../core/workspace-repo.js'; +import { initWorkspace } from '../../core/workspace.js'; +import { + type ClientEntry, + ClientEntrySchema, + ClientTypeSchema, + InstallModeSchema, +} from '../../models/workspace-config.js'; +import { formatPluginSource } from '../../utils/plugin-path.js'; +import { + buildSyncData, + formatManagedRepoResults, + formatMcpResult, + formatNativeResult, + formatPluginArtifacts, + formatPluginHeader, + formatSyncHeader, + formatSyncSummary, +} from '../format-sync.js'; import { buildDescription, conciseSubcommands } from '../help.js'; -import { initMeta, syncMeta, statusMeta, pruneMeta } from '../metadata/workspace.js'; -import { ClientTypeSchema, InstallModeSchema, ClientEntrySchema, type ClientEntry } from '../../models/workspace-config.js'; -import { repoAddMeta, repoRemoveMeta, repoListMeta } from '../metadata/workspace-repo.js'; -import { formatMcpResult, formatNativeResult, buildSyncData, formatPluginArtifacts, formatSyncSummary, formatSyncHeader, formatPluginHeader, formatManagedRepoResults } from '../format-sync.js'; - +import { isJsonMode, jsonOutput } from '../json-output.js'; +import { + repoAddMeta, + repoListMeta, + repoRemoveMeta, +} from '../metadata/workspace-repo.js'; +import { + initMeta, + pruneMeta, + statusMeta, + syncMeta, +} from '../metadata/workspace.js'; // ============================================================================= // workspace init @@ -27,7 +63,10 @@ import { formatMcpResult, formatNativeResult, buildSyncData, formatPluginArtifac export function parseClientEntries(input: string): ClientEntry[] { const entries: ClientEntry[] = []; - for (const part of input.split(',').map((s) => s.trim()).filter(Boolean)) { + for (const part of input + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { const result = ClientEntrySchema.safeParse(part); if (!result.success) { // Provide user-friendly error messages @@ -59,9 +98,23 @@ const initCmd = command({ description: buildDescription(initMeta), args: { path: positional({ type: optional(string), displayName: 'path' }), - from: option({ type: optional(string), long: 'from', description: 'Copy workspace.yaml from existing template/workspace' }), - client: option({ type: optional(string), long: 'client', short: 'c', description: 'Comma-separated clients with optional :mode (e.g., claude:native,copilot,cursor)' }), - force: flag({ long: 'force', short: 'f', description: 'Overwrite existing workspace.yaml' }), + from: option({ + type: optional(string), + long: 'from', + description: 'Copy workspace.yaml from existing template/workspace', + }), + client: option({ + type: optional(string), + long: 'client', + short: 'c', + description: + 'Comma-separated clients with optional :mode (e.g., claude:native,copilot,cursor)', + }), + force: flag({ + long: 'force', + short: 'f', + description: 'Overwrite existing workspace.yaml', + }), }, handler: async ({ path, from, client, force }) => { try { @@ -75,7 +128,11 @@ const initCmd = command({ const prompted = await promptForClients(); if (prompted === null) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace init', error: 'Cancelled' }); + jsonOutput({ + success: false, + command: 'workspace init', + error: 'Cancelled', + }); } return; } @@ -89,7 +146,9 @@ const initCmd = command({ }); if (isJsonMode()) { - const syncData = result.syncResult ? buildSyncData(result.syncResult) : null; + const syncData = result.syncResult + ? buildSyncData(result.syncResult) + : null; jsonOutput({ success: true, command: 'workspace init', @@ -120,7 +179,11 @@ const initCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace init', error: error.message }); + jsonOutput({ + success: false, + command: 'workspace init', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -140,11 +203,30 @@ const syncCmd = command({ aliases: ['sync'], description: buildDescription(syncMeta), args: { - offline: flag({ long: 'offline', description: 'Use cached plugins without fetching latest from remote' }), - dryRun: flag({ long: 'dry-run', short: 'n', description: 'Simulate sync without making changes' }), - force: flag({ long: 'force', short: 'f', description: 'Overwrite existing MCP server entries that differ from plugin config' }), - verbose: flag({ long: 'verbose', short: 'v', description: 'Show informational sync messages' }), - noManaged: flag({ long: 'no-managed', description: 'Skip managed repository clone/pull operations' }), + offline: flag({ + long: 'offline', + description: 'Use cached plugins without fetching latest from remote', + }), + dryRun: flag({ + long: 'dry-run', + short: 'n', + description: 'Simulate sync without making changes', + }), + force: flag({ + long: 'force', + short: 'f', + description: + 'Overwrite existing MCP server entries that differ from plugin config', + }), + verbose: flag({ + long: 'verbose', + short: 'v', + description: 'Show informational sync messages', + }), + noManaged: flag({ + long: 'no-managed', + description: 'Skip managed repository clone/pull operations', + }), }, handler: async ({ offline, dryRun, force, verbose, noManaged }) => { try { @@ -153,16 +235,26 @@ const syncCmd = command({ } const userConfigExists = !!(await getUserWorkspaceConfig()); - const projectConfigPath = join(process.cwd(), '.allagents', 'workspace.yaml'); + const projectConfigPath = join( + process.cwd(), + '.allagents', + 'workspace.yaml', + ); const projectConfigExists = existsSync(projectConfigPath); // If neither config exists, auto-create user config and show guidance if (!userConfigExists && !projectConfigExists) { await ensureUserWorkspace(); if (isJsonMode()) { - jsonOutput({ success: true, command: 'workspace sync', data: { message: 'No plugins configured' } }); + jsonOutput({ + success: true, + command: 'workspace sync', + data: { message: 'No plugins configured' }, + }); } else { - console.log('No plugins configured. Run `allagents plugin install ` to get started.'); + console.log( + 'No plugins configured. Run `allagents plugin install ` to get started.', + ); } return; } @@ -186,7 +278,9 @@ const syncCmd = command({ dryRun, skipManaged: noManaged, }); - combined = combined ? mergeSyncResults(combined, projectResult) : projectResult; + combined = combined + ? mergeSyncResults(combined, projectResult) + : projectResult; } // At this point, at least one config existed so combined is set @@ -221,7 +315,9 @@ const syncCmd = command({ // Print managed repo results if (result.managedRepoResults && result.managedRepoResults.length > 0) { - for (const line of formatManagedRepoResults(result.managedRepoResults)) { + for (const line of formatManagedRepoResults( + result.managedRepoResults, + )) { console.log(line); } console.log(''); @@ -245,14 +341,22 @@ const syncCmd = command({ console.log(line); } - const generated = pluginResult.copyResults.filter((r) => r.action === 'generated').length; - const failed = pluginResult.copyResults.filter((r) => r.action === 'failed').length; + const generated = pluginResult.copyResults.filter( + (r) => r.action === 'generated', + ).length; + const failed = pluginResult.copyResults.filter( + (r) => r.action === 'failed', + ).length; if (generated > 0) console.log(` Generated: ${generated} files`); if (failed > 0) { console.log(` Failed: ${failed} files`); - for (const failedResult of pluginResult.copyResults.filter((r) => r.action === 'failed')) { - console.log(` - ${failedResult.destination}: ${failedResult.error}`); + for (const failedResult of pluginResult.copyResults.filter( + (r) => r.action === 'failed', + )) { + console.log( + ` - ${failedResult.destination}: ${failedResult.error}`, + ); } } } @@ -311,14 +415,21 @@ const syncCmd = command({ if (process.env.ALLAGENTS_DEBUG?.includes('timing') && result.timing) { console.error(''); const totalMs = result.timing.totalMs; - console.error(`[debug] Sync timing (total: ${formatTimingMs(totalMs)})`); + console.error( + `[debug] Sync timing (total: ${formatTimingMs(totalMs)})`, + ); console.error(`[debug] ${'─'.repeat(56)}`); for (const step of result.timing.steps) { - const pct = totalMs > 0 ? ((step.durationMs / totalMs) * 100).toFixed(1) : '0.0'; + const pct = + totalMs > 0 + ? ((step.durationMs / totalMs) * 100).toFixed(1) + : '0.0'; const detail = step.detail ? ` [${step.detail}]` : ''; const label = step.label.padEnd(40); const duration = formatTimingMs(step.durationMs).padStart(8); - console.error(`[debug] ${label} ${duration} ${pct.padStart(5)}%${detail}`); + console.error( + `[debug] ${label} ${duration} ${pct.padStart(5)}%${detail}`, + ); } console.error(`[debug] ${'─'.repeat(56)}`); } @@ -329,7 +440,11 @@ const syncCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace sync', error: error.message }); + jsonOutput({ + success: false, + command: 'workspace sync', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -349,6 +464,24 @@ function formatTimingMs(ms: number): string { // workspace status // ============================================================================= +function formatPluginStatusLine(plugin: { + source: string; + type: 'local' | 'github' | 'marketplace'; + available: boolean; +}): string { + const status = plugin.available ? '✓' : '✗'; + let typeLabel: string | undefined; + if (plugin.type === 'marketplace') { + typeLabel = plugin.available ? undefined : 'not synced'; + } else if (plugin.type === 'github') { + typeLabel = plugin.available ? 'cached' : 'not cached'; + } else { + typeLabel = 'local'; + } + const suffix = typeLabel ? ` (${typeLabel})` : ''; + return `${status} ${formatPluginSource(plugin.source)}${suffix}`; +} + const statusCmd = command({ name: 'status', description: buildDescription(statusMeta), @@ -359,7 +492,11 @@ const statusCmd = command({ if (!result.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace status', error: result.error ?? 'Unknown error' }); + jsonOutput({ + success: false, + command: 'workspace status', + error: result.error ?? 'Unknown error', + }); process.exit(1); } console.error(`Error: ${result.error}`); @@ -370,7 +507,11 @@ const statusCmd = command({ jsonOutput({ success: true, command: 'workspace status', - data: { plugins: result.plugins, userPlugins: result.userPlugins ?? [], clients: result.clients }, + data: { + plugins: result.plugins, + userPlugins: result.userPlugins ?? [], + clients: result.clients, + }, }); return; } @@ -381,17 +522,7 @@ const statusCmd = command({ console.log(' No plugins configured'); } else { for (const plugin of result.plugins) { - const status = plugin.available ? '\u2713' : '\u2717'; - let typeLabel: string | undefined; - if (plugin.type === 'marketplace') { - typeLabel = plugin.available ? undefined : 'not synced'; - } else if (plugin.type === 'github') { - typeLabel = plugin.available ? 'cached' : 'not cached'; - } else { - typeLabel = 'local'; - } - const suffix = typeLabel ? ` (${typeLabel})` : ''; - console.log(` ${status} ${plugin.source}${suffix}`); + console.log(` ${formatPluginStatusLine(plugin)}`); } } @@ -402,17 +533,7 @@ const statusCmd = command({ console.log(' No user plugins configured'); } else { for (const plugin of result.userPlugins) { - const status = plugin.available ? '\u2713' : '\u2717'; - let typeLabel: string | undefined; - if (plugin.type === 'marketplace') { - typeLabel = plugin.available ? undefined : 'not synced'; - } else if (plugin.type === 'github') { - typeLabel = plugin.available ? 'cached' : 'not cached'; - } else { - typeLabel = 'local'; - } - const suffix = typeLabel ? ` (${typeLabel})` : ''; - console.log(` ${status} ${plugin.source}${suffix}`); + console.log(` ${formatPluginStatusLine(plugin)}`); } } } @@ -427,7 +548,11 @@ const statusCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace status', error: error.message }); + jsonOutput({ + success: false, + command: 'workspace status', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -459,7 +584,8 @@ const pruneCmd = command({ return; } - const totalRemoved = result.project.removed.length + result.user.removed.length; + const totalRemoved = + result.project.removed.length + result.user.removed.length; if (totalRemoved === 0) { console.log('No orphaned plugins found.'); @@ -467,7 +593,9 @@ const pruneCmd = command({ } if (result.project.removed.length > 0) { - console.log(`Project plugins pruned (${result.project.removed.length}):`); + console.log( + `Project plugins pruned (${result.project.removed.length}):`, + ); for (const p of result.project.removed) { console.log(` - ${p}`); } @@ -484,7 +612,11 @@ const pruneCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace prune', error: error.message }); + jsonOutput({ + success: false, + command: 'workspace prune', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -504,7 +636,12 @@ const repoAddCmd = command({ description: buildDescription(repoAddMeta), args: { path: positional({ type: string, displayName: 'path' }), - description: option({ type: optional(string), long: 'description', short: 'd', description: 'Repository description' }), + description: option({ + type: optional(string), + long: 'description', + short: 'd', + description: 'Repository description', + }), }, handler: async ({ path: repoPath, description }) => { try { @@ -520,7 +657,11 @@ const repoAddCmd = command({ if (!result.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace repo add', error: result.error ?? 'Unknown error' }); + jsonOutput({ + success: false, + command: 'workspace repo add', + error: result.error ?? 'Unknown error', + }); process.exit(1); } console.error(`Error: ${result.error}`); @@ -550,7 +691,11 @@ const repoAddCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace repo add', error: error.message }); + jsonOutput({ + success: false, + command: 'workspace repo add', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -577,7 +722,11 @@ const repoRemoveCmd = command({ if (!result.success) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace repo remove', error: result.error ?? 'Unknown error' }); + jsonOutput({ + success: false, + command: 'workspace repo remove', + error: result.error ?? 'Unknown error', + }); process.exit(1); } console.error(`Error: ${result.error}`); @@ -588,7 +737,11 @@ const repoRemoveCmd = command({ await updateAgentFiles(); if (isJsonMode()) { - jsonOutput({ success: true, command: 'workspace repo remove', data: { path: repoPath } }); + jsonOutput({ + success: true, + command: 'workspace repo remove', + data: { path: repoPath }, + }); return; } @@ -596,7 +749,11 @@ const repoRemoveCmd = command({ } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace repo remove', error: error.message }); + jsonOutput({ + success: false, + command: 'workspace repo remove', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -638,15 +795,21 @@ const repoListCmd = command({ console.log('Repositories:\n'); for (const repo of repos) { console.log(` ${repo.path}`); - if (repo.source && repo.repo) console.log(` Source: ${repo.source} (${repo.repo})`); - if (repo.description) console.log(` Description: ${repo.description}`); + if (repo.source && repo.repo) + console.log(` Source: ${repo.source} (${repo.repo})`); + if (repo.description) + console.log(` Description: ${repo.description}`); console.log(); } console.log(`Total: ${repos.length} repository(ies)`); } catch (error) { if (error instanceof Error) { if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace repo list', error: error.message }); + jsonOutput({ + success: false, + command: 'workspace repo list', + error: error.message, + }); process.exit(1); } console.error(`Error: ${error.message}`); @@ -679,7 +842,8 @@ export { syncCmd, initCmd }; export const workspaceCmd = conciseSubcommands({ name: 'workspace', - description: 'Manage AI agent workspaces - initialize, sync, and configure plugins', + description: + 'Manage AI agent workspaces - initialize, sync, and configure plugins', cmds: { init: initCmd, sync: syncCmd, diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index 31d596b..b19378d 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -11,7 +11,12 @@ export const skillsListMeta: AgentCommandMeta = { ], expectedOutput: 'Lists skills grouped by plugin with enabled/disabled status', options: [ - { flag: '--scope', short: '-s', type: 'string', description: 'Scope: "project" (default) or "user"' }, + { + flag: '--scope', + short: '-s', + type: 'string', + description: 'Scope: "project" (default) or "user"', + }, ], outputSchema: { skills: [{ name: 'string', plugin: 'string', disabled: 'boolean' }], @@ -30,11 +35,26 @@ export const skillsRemoveMeta: AgentCommandMeta = { ], expectedOutput: 'Confirms skill was disabled and runs sync', positionals: [ - { name: 'skill', type: 'string', required: true, description: 'Skill name to disable' }, + { + name: 'skill', + type: 'string', + required: true, + description: 'Skill name to disable', + }, ], options: [ - { flag: '--scope', short: '-s', type: 'string', description: 'Scope: "project" (default) or "user"' }, - { flag: '--plugin', short: '-p', type: 'string', description: 'Plugin name (required if skill exists in multiple plugins)' }, + { + flag: '--scope', + short: '-s', + type: 'string', + description: 'Scope: "project" (default) or "user"', + }, + { + flag: '--plugin', + short: '-p', + type: 'string', + description: 'Plugin name (required if skill exists in multiple plugins)', + }, ], outputSchema: { skill: 'string', @@ -45,7 +65,8 @@ export const skillsRemoveMeta: AgentCommandMeta = { export const skillsSearchMeta: AgentCommandMeta = { command: 'skill search', - description: 'Search GitHub for skills by querying SKILL.md files via the Code Search API. Results are ranked by relevance, with skill-name matches first. In TTY mode, shows a filter-as-you-type multi-select picker and offers to install the selected skills.', + description: + 'Search GitHub for skills by querying SKILL.md files via the Code Search API. Results are ranked by relevance, with skill-name matches first. In TTY mode, shows a filter-as-you-type multi-select picker and offers to install the selected skills.', whenToUse: 'To discover available skills from public GitHub repositories without leaving the CLI. Bridges "I want a skill that does X" → install.', examples: [ @@ -56,18 +77,46 @@ export const skillsSearchMeta: AgentCommandMeta = { 'allagents skill search docs --page 2 --limit 10', 'allagents --json skill search docs --limit 5', ], - expectedOutput: 'Skills ranked by relevance: repo, skill name, stars, description. In TTY mode, followed by a searchable multi-select install prompt.', + expectedOutput: + 'Skills ranked by relevance: repo, skill name, stars, description. In TTY mode, followed by a searchable multi-select install prompt.', positionals: [ - { name: 'query', type: 'string', required: true, description: 'Search query (≥2 characters).' }, + { + name: 'query', + type: 'string', + required: true, + description: 'Search query (≥2 characters).', + }, ], options: [ - { flag: '--owner', type: 'string', description: 'Scope to a single GitHub owner (org or user).' }, - { flag: '--page', type: 'string', description: 'Result page (1-indexed, default 1).' }, - { flag: '--limit', type: 'string', description: 'Results per page (1–100, default 15).' }, + { + flag: '--owner', + type: 'string', + description: 'Scope to a single GitHub owner (org or user).', + }, + { + flag: '--page', + type: 'string', + description: 'Result page (1-indexed, default 1).', + }, + { + flag: '--limit', + type: 'string', + description: 'Results per page (1–100, default 15).', + }, ], outputSchema: { query: 'string', - items: [{ name: 'string', namespace: 'string', repo: 'string', path: 'string', description: 'string', sha: 'string', stars: 'number' }], + items: [ + { + name: 'string', + namespace: 'string', + repo: 'string', + path: 'string', + description: 'string', + sha: 'string', + stars: 'number', + }, + ], total: 'number', truncated: 'boolean', }, @@ -75,11 +124,16 @@ export const skillsSearchMeta: AgentCommandMeta = { export const skillsAddMeta: AgentCommandMeta = { command: 'skill add', - description: 'Add a skill from a plugin, or re-enable a previously disabled skill', + description: + 'Add a skill from a plugin, or re-enable a previously disabled skill', whenToUse: 'To add a skill from a GitHub repo or marketplace plugin, or to re-enable a skill that was previously disabled', examples: [ 'allagents skill add reddit --from ReScienceLab/opc-skills', + 'allagents skill add NousResearch/hermes-agent --skill llm-wiki', + 'allagents skill add NousResearch/hermes-agent --skill llm-wiki,dogfood', + 'allagents skill add NousResearch/hermes-agent --list', + 'allagents skill add NousResearch/hermes-agent --all', 'allagents skill add https://github.com/owner/repo/tree/main/skills/my-skill', 'allagents skill add brainstorming', 'allagents skill add brainstorming --plugin superpowers', @@ -89,14 +143,20 @@ export const skillsAddMeta: AgentCommandMeta = { expectedOutput: 'Confirms skill was enabled and runs sync', positionals: [ { - name: 'skill', + name: 'skill-or-source', type: 'string', required: false, - description: 'Skill name to add, or a GitHub URL pointing to a skill. Omit with --list or --all.', + description: + 'Either a skill name (paired with --from) or a plugin source (owner/repo, gh:..., or a GitHub URL — paired with --skill, --list, or --all).', }, ], options: [ - { flag: '--scope', short: '-s', type: 'string', description: 'Scope: "project" (default) or "user"' }, + { + flag: '--scope', + short: '-s', + type: 'string', + description: 'Scope: "project" (default) or "user"', + }, { flag: '--plugin', short: '-p', @@ -108,18 +168,24 @@ export const skillsAddMeta: AgentCommandMeta = { short: '-f', type: 'string', description: - 'Plugin source (GitHub URL, owner/repo, or plugin@marketplace) to install if the skill is not already available', + 'Plugin source (GitHub URL, owner/repo, or plugin@marketplace) to install the skill from. Omit when the positional is already a source.', + }, + { + flag: '--skill', + type: 'string', + description: + 'Comma-separated skill names to install when the positional is a plugin source (e.g., `skill add owner/repo --skill foo,bar`).', }, { flag: '--list', short: '-l', type: 'boolean', - description: 'List skills at the --from source without installing', + description: 'List skills available at the source without installing', }, { flag: '--all', type: 'boolean', - description: 'Install every skill from the --from source', + description: 'Install every skill from the source', }, ], outputSchema: { diff --git a/src/core/skills.ts b/src/core/skills.ts index a973ff3..be62ab6 100644 --- a/src/core/skills.ts +++ b/src/core/skills.ts @@ -1,13 +1,20 @@ import { existsSync } from 'node:fs'; import { readFile, readdir } from 'node:fs/promises'; -import { join, basename, resolve } from 'node:path'; +import { basename, join, relative, resolve } from 'node:path'; import { load } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; -import { getPluginSource, type WorkspaceConfig, type PluginSkillsConfig } from '../models/workspace-config.js'; -import { fetchPlugin, getPluginName } from './plugin.js'; +import { + type PluginSkillsConfig, + type WorkspaceConfig, + getPluginSource, +} from '../models/workspace-config.js'; import { isGitHubUrl, parseGitHubUrl } from '../utils/plugin-path.js'; -import { isPluginSpec, resolvePluginSpecWithAutoRegister } from './marketplace.js'; import { parseSkillMetadata } from '../validators/skill.js'; +import { + isPluginSpec, + resolvePluginSpecWithAutoRegister, +} from './marketplace.js'; +import { fetchPlugin, getPluginName } from './plugin.js'; /** * Information about a skill from an installed plugin @@ -29,10 +36,20 @@ export interface SkillInfo { * 'none' = no inline skills field (all skills enabled by default). */ pluginSkillsMode: 'allowlist' | 'blocklist' | 'none'; + /** + * Path from the scan root (a plugin's `skills/` dir, or the plugin root when + * there is no `skills/`) to this skill's directory. For depth-1 skills this + * is the same as `name`; for nested skills it includes parent segments + * (e.g. `research/llm-wiki`). Used for disambiguation when multiple skills + * share a leaf name. + */ + skillSubpath: string; } export interface DiscoveredSkillEntry { name: string; + /** Path from the scan root to the skill directory (POSIX-style separators). */ + subpath: string; skillPath: string; } @@ -82,20 +99,30 @@ async function resolvePluginPath( return existsSync(resolved) ? { path: resolved } : null; } -export async function discoverNestedSkillEntries(pluginPath: string): Promise { - const entries = await readdir(pluginPath, { withFileTypes: true }); +export async function discoverNestedSkillEntries( + scanRoot: string, +): Promise { + return walkForSkillMd(scanRoot, scanRoot); +} + +async function walkForSkillMd( + scanRoot: string, + currentDir: string, +): Promise { + const entries = await readdir(currentDir, { withFileTypes: true }); const discovered: DiscoveredSkillEntry[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; - const skillPath = join(pluginPath, entry.name); + const skillPath = join(currentDir, entry.name); if (existsSync(join(skillPath, 'SKILL.md'))) { - discovered.push({ name: entry.name, skillPath }); + const subpath = relative(scanRoot, skillPath).split(/[\\/]/).join('/'); + discovered.push({ name: entry.name, subpath, skillPath }); continue; } - discovered.push(...await discoverNestedSkillEntries(skillPath)); + discovered.push(...(await walkForSkillMd(scanRoot, skillPath))); } return discovered; @@ -120,8 +147,11 @@ export async function getAllSkillsFromPlugins( // v1 fallback: use top-level disabledSkills/enabledSkills only for configs that haven't migrated const isV1Fallback = config.version === undefined || config.version < 2; - const disabledSkills = isV1Fallback ? new Set(config.disabledSkills ?? []) : new Set(); - const enabledSkills = isV1Fallback && config.enabledSkills ? new Set(config.enabledSkills) : null; + const disabledSkills = isV1Fallback + ? new Set(config.disabledSkills ?? []) + : new Set(); + const enabledSkills = + isV1Fallback && config.enabledSkills ? new Set(config.enabledSkills) : null; const skills: SkillInfo[] = []; @@ -139,16 +169,15 @@ export async function getAllSkillsFromPlugins( typeof pluginEntry === 'string' ? undefined : pluginEntry.skills; // v1 fallback: only apply enabledSkills to plugins with entries in the set - const hasEnabledEntries = !pluginSkillsConfig && enabledSkills && + const hasEnabledEntries = + !pluginSkillsConfig && + enabledSkills && [...enabledSkills].some((s) => s.startsWith(`${pluginName}`)); - let skillEntries: { name: string; skillPath: string }[]; + let skillEntries: DiscoveredSkillEntry[]; if (existsSync(skillsDir)) { - // Standard layout: plugin/skills// - const entries = await readdir(skillsDir, { withFileTypes: true }); - skillEntries = entries - .filter((e) => e.isDirectory()) - .map((e) => ({ name: e.name, skillPath: join(skillsDir, e.name) })); + // Standard layout: plugin/skills//, possibly nested deeper. + skillEntries = await discoverNestedSkillEntries(skillsDir); } else { const nestedSkills = await discoverNestedSkillEntries(pluginPath); if (nestedSkills.length > 0) { @@ -160,7 +189,9 @@ export async function getAllSkillsFromPlugins( const skillContent = await readFile(rootSkillMd, 'utf-8'); const metadata = parseSkillMetadata(skillContent); const skillName = metadata?.name ?? basename(pluginPath); - skillEntries = [{ name: skillName, skillPath: pluginPath }]; + skillEntries = [ + { name: skillName, subpath: skillName, skillPath: pluginPath }, + ]; } else { skillEntries = []; } @@ -168,28 +199,35 @@ export async function getAllSkillsFromPlugins( } const pluginSkillsMode: SkillInfo['pluginSkillsMode'] = - pluginSkillsConfig === undefined ? 'none' - : Array.isArray(pluginSkillsConfig) ? 'allowlist' - : 'blocklist'; + pluginSkillsConfig === undefined + ? 'none' + : Array.isArray(pluginSkillsConfig) + ? 'allowlist' + : 'blocklist'; - for (const { name, skillPath } of skillEntries) { + for (const { name, subpath, skillPath } of skillEntries) { const skillKey = `${pluginName}:${name}`; + const qualifiedKey = `${pluginName}:${subpath}`; let isDisabled: boolean; if (pluginSkillsConfig !== undefined) { - // Inline skills config takes priority (v2+) + // Inline skills config takes priority (v2+). Allow either bare leaf + // name (`llm-wiki`) or qualified subpath (`research/llm-wiki`) so + // users can disambiguate when multiple skills share a leaf name. if (Array.isArray(pluginSkillsConfig)) { - // allowlist: disabled if NOT in array - isDisabled = !pluginSkillsConfig.includes(name); + isDisabled = + !pluginSkillsConfig.includes(name) && + !pluginSkillsConfig.includes(subpath); } else { - // blocklist: disabled if IS in exclude array - isDisabled = pluginSkillsConfig.exclude.includes(name); + isDisabled = + pluginSkillsConfig.exclude.includes(name) || + pluginSkillsConfig.exclude.includes(subpath); } } else if (isV1Fallback) { // v1 fallback: top-level disabledSkills/enabledSkills isDisabled = hasEnabledEntries - ? !enabledSkills?.has(skillKey) - : disabledSkills.has(skillKey); + ? !(enabledSkills?.has(skillKey) || enabledSkills?.has(qualifiedKey)) + : disabledSkills.has(skillKey) || disabledSkills.has(qualifiedKey); } else { isDisabled = false; } @@ -201,6 +239,7 @@ export async function getAllSkillsFromPlugins( path: skillPath, disabled: isDisabled, pluginSkillsMode, + skillSubpath: subpath, }); } } @@ -228,29 +267,41 @@ export async function findSkillByName( * @param pluginPath - Path to plugin directory * @returns Array of skill names */ -export async function discoverSkillNames(pluginPath: string): Promise { +export async function discoverSkillNames( + pluginPath: string, +): Promise { + return (await discoverSkillEntries(pluginPath)).map((entry) => entry.name); +} + +/** + * Discover skill entries (name + subpath) from a plugin directory without + * workspace config. Mirrors `discoverSkillNames` but exposes the qualified + * path for disambiguation when nested skills share a leaf name. + */ +export async function discoverSkillEntries( + pluginPath: string, +): Promise { if (!existsSync(pluginPath)) return []; const skillsDir = join(pluginPath, 'skills'); if (existsSync(skillsDir)) { - const entries = await readdir(skillsDir, { withFileTypes: true }); - return entries.filter((e) => e.isDirectory()).map((e) => e.name); + return discoverNestedSkillEntries(skillsDir); } const nestedSkills = await discoverNestedSkillEntries(pluginPath); - if (nestedSkills.length > 0) return nestedSkills.map((entry) => entry.name); + if (nestedSkills.length > 0) return nestedSkills; - // Root-level SKILL.md const rootSkillMd = join(pluginPath, 'SKILL.md'); if (existsSync(rootSkillMd)) { + let name = basename(pluginPath); try { const content = await readFile(rootSkillMd, 'utf-8'); - const { parseSkillMetadata } = await import('../validators/skill.js'); const metadata = parseSkillMetadata(content); - return [metadata?.name ?? basename(pluginPath)]; + if (metadata?.name) name = metadata.name; } catch { - return [basename(pluginPath)]; + // Fall through to basename } + return [{ name, subpath: name, skillPath: pluginPath }]; } return []; diff --git a/src/core/transform.ts b/src/core/transform.ts index 909ec5c..9952b20 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -1,15 +1,27 @@ -import { readFile, writeFile, mkdir, cp, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { join, basename, dirname, relative } from 'node:path'; +import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, relative } from 'node:path'; import micromatch from 'micromatch'; -import { resolveGlobPatterns, isGlobPattern } from '../utils/glob-patterns.js'; -import { CLIENT_MAPPINGS, isUniversalClient } from '../models/client-mapping.js'; +import { + type SkillsIndexRef, + type WorkspaceRepository, + generateWorkspaceRules, +} from '../constants.js'; +import { + CLIENT_MAPPINGS, + isUniversalClient, +} from '../models/client-mapping.js'; import type { ClientMapping } from '../models/client-mapping.js'; -import type { ClientType, WorkspaceFile, SyncMode, PluginSkillsConfig } from '../models/workspace-config.js'; -import { generateWorkspaceRules, type WorkspaceRepository, type SkillsIndexRef } from '../constants.js'; +import type { + ClientType, + PluginSkillsConfig, + SyncMode, + WorkspaceFile, +} from '../models/workspace-config.js'; +import { isGlobPattern, resolveGlobPatterns } from '../utils/glob-patterns.js'; +import { adjustLinksInContent } from '../utils/link-adjuster.js'; import { parseFileSource } from '../utils/plugin-path.js'; import { createSymlink } from '../utils/symlink.js'; -import { adjustLinksInContent } from '../utils/link-adjuster.js'; import { parseSkillMetadata } from '../validators/skill.js'; import { discoverNestedSkillEntries } from './skills.js'; @@ -85,7 +97,11 @@ export interface CopyOptions { /** * Check if a file path (relative to plugin root) matches any exclude pattern. */ -function isExcluded(pluginPath: string, filePath: string, exclude?: string[]): boolean { +function isExcluded( + pluginPath: string, + filePath: string, + exclude?: string[], +): boolean { if (!exclude || exclude.length === 0) return false; const relativePath = relative(pluginPath, filePath).replaceAll('\\', '/'); return micromatch.isMatch(relativePath, exclude); @@ -112,7 +128,12 @@ async function copyDirectoryWithExclusions( } if (entry.isDirectory()) { - await copyDirectoryWithExclusions(sourcePath, destPath, pluginPath, exclude); + await copyDirectoryWithExclusions( + sourcePath, + destPath, + pluginPath, + exclude, + ); } else { await cp(sourcePath, destPath); } @@ -168,8 +189,14 @@ export interface WorkspaceCopyOptions extends CopyOptions { /** * Get the client mapping, using override if provided, otherwise falling back to CLIENT_MAPPINGS */ -function getMapping(client: ClientType, options?: { clientMappings?: Record }): ClientMapping { - return (options?.clientMappings as Record)?.[client] ?? CLIENT_MAPPINGS[client]; +function getMapping( + client: ClientType, + options?: { clientMappings?: Record }, +): ClientMapping { + return ( + (options?.clientMappings as Record)?.[client] ?? + CLIENT_MAPPINGS[client] + ); } /** @@ -211,7 +238,9 @@ export async function copyCommands( // Process files in parallel for better performance const copyPromises = mdFiles - .filter((file) => !isExcluded(pluginPath, join(sourceDir, file), options.exclude)) + .filter( + (file) => !isExcluded(pluginPath, join(sourceDir, file), options.exclude), + ) .map(async (file): Promise => { const sourcePath = join(sourceDir, file); const destPath = join(destDir, file); @@ -258,7 +287,12 @@ export async function copySkills( client: ClientType, options: SkillCopyOptions = {}, ): Promise { - const { dryRun = false, skillNameMap, syncMode = 'copy', canonicalSkillsPath } = options; + const { + dryRun = false, + skillNameMap, + syncMode = 'copy', + canonicalSkillsPath, + } = options; const mapping = getMapping(client, options); const results: CopyResult[] = []; @@ -267,21 +301,40 @@ export async function copySkills( return results; } - // Discover skill sources across all layouts + // Discover skill sources across all layouts. Skills can be nested below + // `skills//`; the subpath relative to the scan root is preserved + // so the allowlist can target nested skills with `category/skill` form. const skillsDir = join(pluginPath, 'skills'); - let skillSources: { name: string; sourcePath: string; isRootLevel: boolean }[]; + let skillSources: { + name: string; + subpath: string; + sourcePath: string; + isRootLevel: boolean; + }[]; if (existsSync(skillsDir)) { - // Standard layout: plugin/skills// - const entries = await readdir(skillsDir, { withFileTypes: true }); + const entries = await discoverNestedSkillEntries(skillsDir); skillSources = entries - .filter((e) => e.isDirectory()) - .filter((e) => !isExcluded(pluginPath, join(skillsDir, e.name), options.exclude)) - .map((e) => ({ name: e.name, sourcePath: join(skillsDir, e.name), isRootLevel: false })); + .filter( + (entry) => !isExcluded(pluginPath, entry.skillPath, options.exclude), + ) + .map((entry) => ({ + name: entry.name, + subpath: entry.subpath, + sourcePath: entry.skillPath, + isRootLevel: false, + })); } else { const nestedSkills = (await discoverNestedSkillEntries(pluginPath)) - .filter((entry) => !isExcluded(pluginPath, entry.skillPath, options.exclude)) - .map((entry) => ({ name: entry.name, sourcePath: entry.skillPath, isRootLevel: false })); + .filter( + (entry) => !isExcluded(pluginPath, entry.skillPath, options.exclude), + ) + .map((entry) => ({ + name: entry.name, + subpath: entry.subpath, + sourcePath: entry.skillPath, + isRootLevel: false, + })); if (nestedSkills.length > 0) { skillSources = nestedSkills; @@ -292,16 +345,27 @@ export async function copySkills( const content = await readFile(rootSkillMd, 'utf-8'); const metadata = parseSkillMetadata(content); const skillName = metadata?.name ?? basename(pluginPath); - skillSources = [{ name: skillName, sourcePath: pluginPath, isRootLevel: true }]; + skillSources = [ + { + name: skillName, + subpath: skillName, + sourcePath: pluginPath, + isRootLevel: true, + }, + ]; } else { return results; } } } - // When skillNameMap is provided, only copy skills that are in the map + // When skillNameMap is provided, only copy skills that are in the map. + // Match by either bare leaf name or qualified subpath so the map can carry + // either form. if (skillNameMap) { - skillSources = skillSources.filter((s) => skillNameMap.has(s.name)); + skillSources = skillSources.filter( + (s) => skillNameMap.has(s.name) || skillNameMap.has(s.subpath), + ); } if (skillSources.length === 0) { @@ -314,7 +378,8 @@ export async function copySkills( } // Determine if we should use symlinks for this client - const useSymlinks = syncMode === 'symlink' && !isUniversalClient(client) && canonicalSkillsPath; + const useSymlinks = + syncMode === 'symlink' && !isUniversalClient(client) && canonicalSkillsPath; // Process skill directories in parallel for better performance const copyPromises = skillSources.map(async (skill): Promise => { @@ -332,8 +397,15 @@ export async function copySkills( // If using symlinks, create symlink from client path to canonical location if (useSymlinks) { - const canonicalSkillPath = join(workspacePath, canonicalSkillsPath, resolvedName); - const symlinkCreated = await createSymlink(canonicalSkillPath, skillDestPath); + const canonicalSkillPath = join( + workspacePath, + canonicalSkillsPath, + resolvedName, + ); + const symlinkCreated = await createSymlink( + canonicalSkillPath, + skillDestPath, + ); if (symlinkCreated) { return { @@ -349,9 +421,17 @@ export async function copySkills( if (skill.isRootLevel) { // Root-level: copy only the SKILL.md into a new skill directory await mkdir(skillDestPath, { recursive: true }); - await cp(join(skill.sourcePath, 'SKILL.md'), join(skillDestPath, 'SKILL.md')); + await cp( + join(skill.sourcePath, 'SKILL.md'), + join(skillDestPath, 'SKILL.md'), + ); } else if (options.exclude && options.exclude.length > 0) { - await copyDirectoryWithExclusions(skill.sourcePath, skillDestPath, pluginPath, options.exclude); + await copyDirectoryWithExclusions( + skill.sourcePath, + skillDestPath, + pluginPath, + options.exclude, + ); } else { await cp(skill.sourcePath, skillDestPath, { recursive: true }); } @@ -409,20 +489,28 @@ export async function collectPluginSkills( const skillsDir = join(pluginPath, 'skills'); // v1 fallback: only apply enabledSkills to plugins that actually have entries in the set - const hasEnabledEntries = !pluginSkillsConfig && enabledSkills && pluginName && + const hasEnabledEntries = + !pluginSkillsConfig && + enabledSkills && + pluginName && [...enabledSkills].some((s) => s.startsWith(`${pluginName}:`)); - let candidateDirs: { name: string; path: string }[]; + let candidateDirs: { name: string; subpath: string; path: string }[]; if (existsSync(skillsDir)) { - // Standard layout: plugin/skills// - const entries = await readdir(skillsDir, { withFileTypes: true }); - candidateDirs = entries - .filter((e) => e.isDirectory()) - .map((e) => ({ name: e.name, path: join(skillsDir, e.name) })); + const entries = await discoverNestedSkillEntries(skillsDir); + candidateDirs = entries.map((entry) => ({ + name: entry.name, + subpath: entry.subpath, + path: entry.skillPath, + })); } else { - const nestedDirs = await discoverNestedSkillEntries(pluginPath).then((entries) => - entries.map((entry) => ({ name: entry.name, path: entry.skillPath })) + const nestedDirs = (await discoverNestedSkillEntries(pluginPath)).map( + (entry) => ({ + name: entry.name, + subpath: entry.subpath, + path: entry.skillPath, + }), ); if (nestedDirs.length > 0) { @@ -434,29 +522,48 @@ export async function collectPluginSkills( const content = await readFile(rootSkillMd, 'utf-8'); const metadata = parseSkillMetadata(content); const skillName = metadata?.name ?? basename(pluginPath); - candidateDirs = [{ name: skillName, path: pluginPath }]; + candidateDirs = [ + { name: skillName, subpath: skillName, path: pluginPath }, + ]; } else { candidateDirs = []; } } } + const matchesAllowlist = ( + entry: { name: string; subpath: string }, + allowlist: string[], + ): boolean => + allowlist.includes(entry.name) || allowlist.includes(entry.subpath); + let filteredDirs: typeof candidateDirs; if (pluginSkillsConfig !== undefined) { - // Inline config takes priority (v2+) + // Inline config takes priority (v2+). Match by either bare leaf name or + // qualified subpath so nested skills can be targeted unambiguously. if (Array.isArray(pluginSkillsConfig)) { - // allowlist: keep only skills in the array - filteredDirs = candidateDirs.filter((e) => pluginSkillsConfig.includes(e.name)); + filteredDirs = candidateDirs.filter((e) => + matchesAllowlist(e, pluginSkillsConfig), + ); } else { - // blocklist: exclude skills in the exclude array - filteredDirs = candidateDirs.filter((e) => !pluginSkillsConfig.exclude.includes(e.name)); + filteredDirs = candidateDirs.filter( + (e) => !matchesAllowlist(e, pluginSkillsConfig.exclude), + ); } } else if (pluginName) { // v1 fallback: use disabledSkills/enabledSkills if (hasEnabledEntries) { - filteredDirs = candidateDirs.filter((e) => enabledSkills?.has(`${pluginName}:${e.name}`)); + filteredDirs = candidateDirs.filter( + (e) => + enabledSkills?.has(`${pluginName}:${e.name}`) || + enabledSkills?.has(`${pluginName}:${e.subpath}`), + ); } else if (disabledSkills) { - filteredDirs = candidateDirs.filter((e) => !disabledSkills.has(`${pluginName}:${e.name}`)); + filteredDirs = candidateDirs.filter( + (e) => + !disabledSkills.has(`${pluginName}:${e.name}`) && + !disabledSkills.has(`${pluginName}:${e.subpath}`), + ); } else { filteredDirs = candidateDirs; } @@ -512,7 +619,12 @@ export async function copyHooks( try { if (options.exclude && options.exclude.length > 0) { - await copyDirectoryWithExclusions(sourceDir, destDir, pluginPath, options.exclude); + await copyDirectoryWithExclusions( + sourceDir, + destDir, + pluginPath, + options.exclude, + ); } else { await cp(sourceDir, destDir, { recursive: true }); } @@ -568,7 +680,9 @@ export async function copyAgents( // Process files in parallel for better performance const copyPromises = mdFiles - .filter((file) => !isExcluded(pluginPath, join(sourceDir, file), options.exclude)) + .filter( + (file) => !isExcluded(pluginPath, join(sourceDir, file), options.exclude), + ) .map(async (file): Promise => { const sourcePath = join(sourceDir, file); const destPath = join(destDir, file); @@ -605,7 +719,6 @@ export interface GitHubCopyOptions extends CopyOptions { skillNameMap?: Map; } - /** * Recursively process a directory, copying files and adjusting links in markdown. * Single-pass approach: read source → transform if markdown → write to dest. @@ -631,10 +744,22 @@ async function copyAndAdjustDirectory( } if (entry.isDirectory()) { - await copyAndAdjustDirectory(sourcePath, destPath, sourceBase, pluginPath, skillsPath, skillNameMap, exclude); + await copyAndAdjustDirectory( + sourcePath, + destPath, + sourceBase, + pluginPath, + skillsPath, + skillNameMap, + exclude, + ); } else { - const relativePath = relative(sourceBase, sourcePath).replaceAll('\\', '/'); - const isMarkdown = entry.name.endsWith('.md') || entry.name.endsWith('.markdown'); + const relativePath = relative(sourceBase, sourcePath).replaceAll( + '\\', + '/', + ); + const isMarkdown = + entry.name.endsWith('.md') || entry.name.endsWith('.markdown'); if (isMarkdown) { // Read, transform, write in one pass @@ -692,7 +817,15 @@ export async function copyGitHubContent( try { // Single-pass: copy files and adjust markdown links in one traversal if (mapping.skillsPath || (options.exclude && options.exclude.length > 0)) { - await copyAndAdjustDirectory(sourceDir, destDir, sourceDir, pluginPath, mapping.skillsPath ?? '', skillNameMap, options.exclude); + await copyAndAdjustDirectory( + sourceDir, + destDir, + sourceDir, + pluginPath, + mapping.skillsPath ?? '', + skillNameMap, + options.exclude, + ); } else { // No skills path and no excludes - just copy without adjustment await cp(sourceDir, destDir, { recursive: true }); @@ -747,28 +880,41 @@ export async function copyPluginToWorkspace( client: ClientType, options: PluginCopyOptions = {}, ): Promise { - const { skillNameMap, syncMode, canonicalSkillsPath, ...baseOptions } = options; + const { skillNameMap, syncMode, canonicalSkillsPath, ...baseOptions } = + options; // Phase 1: Copy root-level artifacts in parallel - const [commandResults, skillResults, hookResults, agentResults] = await Promise.all([ - copyCommands(pluginPath, workspacePath, client, baseOptions), - copySkills(pluginPath, workspacePath, client, { - ...baseOptions, - ...(skillNameMap && { skillNameMap }), - ...(syncMode && { syncMode }), - ...(canonicalSkillsPath && { canonicalSkillsPath }), - }), - copyHooks(pluginPath, workspacePath, client, baseOptions), - copyAgents(pluginPath, workspacePath, client, baseOptions), - ]); + const [commandResults, skillResults, hookResults, agentResults] = + await Promise.all([ + copyCommands(pluginPath, workspacePath, client, baseOptions), + copySkills(pluginPath, workspacePath, client, { + ...baseOptions, + ...(skillNameMap && { skillNameMap }), + ...(syncMode && { syncMode }), + ...(canonicalSkillsPath && { canonicalSkillsPath }), + }), + copyHooks(pluginPath, workspacePath, client, baseOptions), + copyAgents(pluginPath, workspacePath, client, baseOptions), + ]); // Phase 2: Copy .github/ content — overrides root-level on name conflicts - const githubResults = await copyGitHubContent(pluginPath, workspacePath, client, { - ...baseOptions, - ...(skillNameMap && { skillNameMap }), - }); - - return [...commandResults, ...skillResults, ...hookResults, ...agentResults, ...githubResults]; + const githubResults = await copyGitHubContent( + pluginPath, + workspacePath, + client, + { + ...baseOptions, + ...(skillNameMap && { skillNameMap }), + }, + ); + + return [ + ...commandResults, + ...skillResults, + ...hookResults, + ...agentResults, + ...githubResults, + ]; } /** @@ -795,12 +941,21 @@ function isExplicitGitHubSource(source: string): boolean { // For shorthand format (owner/repo/path), require at least 3 segments // This ensures paths like "config/settings.json" are treated as local - if (!source.startsWith('.') && !source.startsWith('/') && source.includes('/')) { + if ( + !source.startsWith('.') && + !source.startsWith('/') && + source.includes('/') + ) { const parts = source.split('/'); // Need owner, repo, AND at least one path segment for file sources if (parts.length >= 3) { const validOwnerRepo = /^[a-zA-Z0-9_.-]+$/; - if (parts[0] && parts[1] && validOwnerRepo.test(parts[0]) && validOwnerRepo.test(parts[1])) { + if ( + parts[0] && + parts[1] && + validOwnerRepo.test(parts[0]) && + validOwnerRepo.test(parts[1]) + ) { return true; } } @@ -853,7 +1008,12 @@ function resolveFileSourcePath( const parsed = parseFileSource(source); // GitHub source - need to resolve from cache - if (parsed.type === 'github' && parsed.owner && parsed.repo && parsed.filePath) { + if ( + parsed.type === 'github' && + parsed.owner && + parsed.repo && + parsed.filePath + ) { const cacheKey = `${parsed.owner}/${parsed.repo}`; const cachePath = githubCache?.get(cacheKey); @@ -900,7 +1060,12 @@ export async function copyWorkspaceFiles( files: WorkspaceFile[], options: WorkspaceCopyOptions = {}, ): Promise { - const { dryRun = false, githubCache, repositories = [], skillsIndexRefs = [] } = options; + const { + dryRun = false, + githubCache, + repositories = [], + skillsIndexRefs = [], + } = options; const results: CopyResult[] = []; // Separate string patterns from object entries @@ -931,7 +1096,9 @@ export async function copyWorkspaceFiles( }); continue; } - objectEntries.push(file.source ? { source: file.source, dest } : { dest }); + objectEntries.push( + file.source ? { source: file.source, dest } : { dest }, + ); } } @@ -950,14 +1117,20 @@ export async function copyWorkspaceFiles( } } } else { - const resolvedFiles = await resolveGlobPatterns(sourcePath, stringPatterns); + const resolvedFiles = await resolveGlobPatterns( + sourcePath, + stringPatterns, + ); for (const resolved of resolvedFiles) { const destPath = join(workspacePath, resolved.relativePath); if (!existsSync(resolved.sourcePath)) { // Only report error for literal (non-glob) patterns const wasLiteral = stringPatterns.some( - (p) => !isGlobPattern(p) && !p.startsWith('!') && p === resolved.relativePath, + (p) => + !isGlobPattern(p) && + !p.startsWith('!') && + p === resolved.relativePath, ); if (wasLiteral) { results.push({ @@ -971,9 +1144,15 @@ export async function copyWorkspaceFiles( } if (dryRun) { - results.push({ source: resolved.sourcePath, destination: destPath, action: 'copied' }); + results.push({ + source: resolved.sourcePath, + destination: destPath, + action: 'copied', + }); // Track agent files even in dry-run for accurate reporting - if ((AGENT_FILES as readonly string[]).includes(resolved.relativePath)) { + if ( + (AGENT_FILES as readonly string[]).includes(resolved.relativePath) + ) { copiedAgentFiles.push(resolved.relativePath); } continue; @@ -983,10 +1162,16 @@ export async function copyWorkspaceFiles( await mkdir(dirname(destPath), { recursive: true }); const content = await readFile(resolved.sourcePath, 'utf-8'); await writeFile(destPath, content, 'utf-8'); - results.push({ source: resolved.sourcePath, destination: destPath, action: 'copied' }); + results.push({ + source: resolved.sourcePath, + destination: destPath, + action: 'copied', + }); // Track if this is an agent file - if ((AGENT_FILES as readonly string[]).includes(resolved.relativePath)) { + if ( + (AGENT_FILES as readonly string[]).includes(resolved.relativePath) + ) { copiedAgentFiles.push(resolved.relativePath); } } catch (error) { @@ -1008,7 +1193,11 @@ export async function copyWorkspaceFiles( if (entry.source) { // Has explicit source - resolve it (can be local or GitHub) - const resolved = resolveFileSourcePath(entry.source, sourcePath, githubCache); + const resolved = resolveFileSourcePath( + entry.source, + sourcePath, + githubCache, + ); if (!resolved) { results.push({ source: entry.source, @@ -1053,7 +1242,11 @@ export async function copyWorkspaceFiles( } if (dryRun) { - results.push({ source: srcPath, destination: destPath, action: 'copied' }); + results.push({ + source: srcPath, + destination: destPath, + action: 'copied', + }); // Track agent files even in dry-run for accurate reporting if ((AGENT_FILES as readonly string[]).includes(entry.dest)) { copiedAgentFiles.push(entry.dest); @@ -1065,7 +1258,11 @@ export async function copyWorkspaceFiles( await mkdir(dirname(destPath), { recursive: true }); const content = await readFile(srcPath, 'utf-8'); await writeFile(destPath, content, 'utf-8'); - results.push({ source: srcPath, destination: destPath, action: 'copied' }); + results.push({ + source: srcPath, + destination: destPath, + action: 'copied', + }); // Track if this is an agent file if ((AGENT_FILES as readonly string[]).includes(entry.dest)) { @@ -1093,7 +1290,10 @@ export async function copyWorkspaceFiles( source: 'WORKSPACE-RULES', destination: targetPath, action: 'failed', - error: error instanceof Error ? error.message : 'Failed to inject WORKSPACE-RULES', + error: + error instanceof Error + ? error.message + : 'Failed to inject WORKSPACE-RULES', }); } } diff --git a/src/utils/plugin-path.ts b/src/utils/plugin-path.ts index 3779d71..c03d0c5 100644 --- a/src/utils/plugin-path.ts +++ b/src/utils/plugin-path.ts @@ -1,13 +1,13 @@ import { existsSync } from 'node:fs'; -import { join, resolve, isAbsolute } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; +import { getHomeDir } from '../constants.js'; import { - repoExists, - cloneToTemp, + GitCloneError, cleanupTempDir, + cloneToTemp, gitHubUrl, - GitCloneError, + repoExists, } from '../core/git.js'; -import { getHomeDir } from '../constants.js'; /** * Plugin source types @@ -36,7 +36,9 @@ export function stripGitRef(spec: string): string { if (atIdx === -1) return spec; const cleanRepo = repoSeg.slice(0, atIdx); const rest = parts.slice(2); - return rest.length === 0 ? `${ownerSeg}/${cleanRepo}` : `${ownerSeg}/${cleanRepo}/${rest.join('/')}`; + return rest.length === 0 + ? `${ownerSeg}/${cleanRepo}` + : `${ownerSeg}/${cleanRepo}/${rest.join('/')}`; } /** @@ -148,7 +150,8 @@ export function parseGitHubUrl( // Split @ref off the repo segment, if present. const atIdx = rawRepo.indexOf('@'); const repo = atIdx === -1 ? rawRepo : rawRepo.slice(0, atIdx); - const branch = atIdx === -1 ? undefined : rawRepo.slice(atIdx + 1) || undefined; + const branch = + atIdx === -1 ? undefined : rawRepo.slice(atIdx + 1) || undefined; if (!validOwnerRepo.test(repo)) return null; if (parts.length > 2) { @@ -294,6 +297,37 @@ export function parsePluginSource( }; } +/** + * Render a plugin source as a short, human-readable label. + * + * Used for display only — the original source string is preserved in + * workspace.yaml. Default branches (`main`/`master`) are elided from the + * label; any other branch is retained as `OWNER/REPO@`. + * + * @param source - Plugin source string (any of the GitHub forms, local path, + * or `plugin@marketplace` shorthand) + * @returns Short display label suitable for CLI status/list output + */ +export function formatPluginSource(source: string): string { + if (!source) return source; + const trimmed = source.trim(); + if (!trimmed) return source; + + // Leave plugin@marketplace specs (no slash before the @) untouched. + if (!trimmed.includes('/') && trimmed.includes('@')) return trimmed; + if (!isGitHubUrl(trimmed)) return trimmed; + + const parsed = parseGitHubUrl(trimmed); + if (!parsed) return trimmed; + + const { owner, repo, branch, subpath } = parsed; + const isDefaultBranch = !branch || branch === 'main' || branch === 'master'; + const base = isDefaultBranch + ? `${owner}/${repo}` + : `${owner}/${repo}@${branch}`; + return subpath ? `${base}/${subpath}` : base; +} + /** * Sanitize a branch name for use in filesystem paths * Replaces slashes and other problematic characters with underscores @@ -312,12 +346,18 @@ function sanitizeBranchForPath(branch: string): string { * @param branch - Optional branch name (if specified, creates branch-specific cache) * @returns Cache directory path */ -export function getPluginCachePath(owner: string, repo: string, branch?: string): string { +export function getPluginCachePath( + owner: string, + repo: string, + branch?: string, +): string { const basePath = `${owner}-${repo}`; // If branch is specified, create a branch-specific cache path // This allows concurrent use of different branches from the same repo - const cacheName = branch ? `${basePath}@${sanitizeBranchForPath(branch)}` : basePath; + const cacheName = branch + ? `${basePath}@${sanitizeBranchForPath(branch)}` + : basePath; return resolve( getHomeDir(), @@ -440,7 +480,8 @@ export async function verifyGitHubUrlExists( if (!parsed) { return { exists: false, - error: 'Invalid GitHub URL format. Expected: https://github.com/owner/repo', + error: + 'Invalid GitHub URL format. Expected: https://github.com/owner/repo', }; } diff --git a/tests/unit/cli/skill-add-classify-positional.test.ts b/tests/unit/cli/skill-add-classify-positional.test.ts new file mode 100644 index 0000000..a28c86f --- /dev/null +++ b/tests/unit/cli/skill-add-classify-positional.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'bun:test'; +import { classifySkillAddPositional } from '../../../src/cli/commands/plugin-skills.js'; + +describe('classifySkillAddPositional', () => { + it('returns none when no positional is provided', () => { + expect(classifySkillAddPositional(undefined, undefined, false, false)).toEqual({ shape: 'none' }); + }); + + it('treats a non-source positional as a skill name', () => { + expect(classifySkillAddPositional('llm-wiki', undefined, false, false)).toEqual({ shape: 'skill-name' }); + }); + + it('treats owner/repo + --skill as a source positional', () => { + expect(classifySkillAddPositional('NousResearch/hermes-agent', 'llm-wiki', false, false)).toEqual({ + shape: 'source', + skills: ['llm-wiki'], + }); + }); + + it('parses comma-separated --skill values', () => { + const result = classifySkillAddPositional('owner/repo', 'foo, bar ,baz', false, false); + expect(result).toEqual({ shape: 'source', skills: ['foo', 'bar', 'baz'] }); + }); + + it('treats a full GitHub URL + --list as a source positional', () => { + expect( + classifySkillAddPositional('https://github.com/owner/repo', undefined, true, false), + ).toEqual({ shape: 'source', skills: [] }); + }); + + it('treats gh: prefix + --all as a source positional', () => { + expect(classifySkillAddPositional('gh:owner/repo', undefined, false, true)).toEqual({ + shape: 'source', + skills: [], + }); + }); + + it('falls back to skill-name for source-shaped positional with no selector (legacy deep-URL form)', () => { + // `resolveSkillFromUrl` handles URL/owner-repo without selectors as legacy + expect( + classifySkillAddPositional( + 'https://github.com/owner/repo/blob/main/skills/foo', + undefined, + false, + false, + ), + ).toEqual({ shape: 'skill-name' }); + }); +}); diff --git a/tests/unit/cli/skills-add-list-all.test.ts b/tests/unit/cli/skills-add-list-all.test.ts index f6a33a0..e0ca22b 100644 --- a/tests/unit/cli/skills-add-list-all.test.ts +++ b/tests/unit/cli/skills-add-list-all.test.ts @@ -87,17 +87,27 @@ describe('discoverSkillsWithMetadata', () => { expect(result[0]?.description).toBe('Root layout skill'); }); - it('returns empty description when no SKILL.md exists anywhere for a discovered skill', async () => { - // discoverSkillNames finds the dir via flat layout check, but SKILL.md is missing - // resolveSkillMdPath falls back to pluginPath/SKILL.md which also doesn't exist - // the catch block should yield description = '' + it('omits directories that lack a SKILL.md', async () => { + // A directory under skills/ with no SKILL.md is not a skill; recursive + // discovery walks past it instead of listing it as an empty entry. await mkdir(join(tmpDir, 'skills/eta'), { recursive: true }); - // Intentionally no SKILL.md written + + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result).toEqual([]); + }); + + it('discovers nested skills below skills//', async () => { + await mkdir(join(tmpDir, 'skills/research/llm-wiki'), { recursive: true }); + await writeFile( + join(tmpDir, 'skills/research/llm-wiki/SKILL.md'), + '---\nname: llm-wiki\ndescription: Wiki of LLMs\n---\n', + ); const result = await discoverSkillsWithMetadata(tmpDir); expect(result).toHaveLength(1); - expect(result[0]?.name).toBe('eta'); - expect(result[0]?.description).toBe(''); + expect(result[0]?.name).toBe('llm-wiki'); + expect(result[0]?.subpath).toBe('research/llm-wiki'); + expect(result[0]?.description).toBe('Wiki of LLMs'); }); it('returns pluginName: undefined when not provided', async () => { diff --git a/tests/unit/core/skill-resolution.test.ts b/tests/unit/core/skill-resolution.test.ts index f627d83..4dc99d8 100644 --- a/tests/unit/core/skill-resolution.test.ts +++ b/tests/unit/core/skill-resolution.test.ts @@ -59,7 +59,9 @@ description: Skill B expect(skills).toHaveLength(0); }); - it('should include skill directories without SKILL.md', async () => { + it('should skip skill directories without SKILL.md', async () => { + // Recursive discovery walks past dirs that lack a SKILL.md, so a stray + // empty folder under skills/ is no longer reported as a skill. const pluginDir = join(testDir, 'plugin-with-stale'); await mkdir(join(pluginDir, 'skills', 'valid-skill'), { recursive: true }); await mkdir(join(pluginDir, 'skills', 'no-skillmd'), { recursive: true }); @@ -73,9 +75,20 @@ description: Valid Skill const skills = await collectPluginSkills(pluginDir, 'test-source'); - expect(skills).toHaveLength(2); - const names = skills.map((s) => s.folderName).sort(); - expect(names).toEqual(['no-skillmd', 'valid-skill']); + expect(skills).toHaveLength(1); + expect(skills[0]?.folderName).toBe('valid-skill'); + }); + + it('should discover nested skills under skills///', async () => { + const pluginDir = join(testDir, 'plugin-nested'); + await mkdir(join(pluginDir, 'skills', 'research', 'llm-wiki'), { recursive: true }); + await mkdir(join(pluginDir, 'skills', 'dogfood'), { recursive: true }); + await writeFile(join(pluginDir, 'skills', 'research', 'llm-wiki', 'SKILL.md'), '# llm-wiki'); + await writeFile(join(pluginDir, 'skills', 'dogfood', 'SKILL.md'), '# dogfood'); + + const skills = await collectPluginSkills(pluginDir, 'test-source'); + + expect(skills.map((s) => s.folderName).sort()).toEqual(['dogfood', 'llm-wiki']); }); it('should ignore files in skills directory (only dirs)', async () => { diff --git a/tests/unit/core/skills.test.ts b/tests/unit/core/skills.test.ts index 87e06d9..23f5ef2 100644 --- a/tests/unit/core/skills.test.ts +++ b/tests/unit/core/skills.test.ts @@ -157,6 +157,68 @@ describe('getAllSkillsFromPlugins', () => { expect(skills.every((s) => s.disabled === false)).toBe(true); }); + it('discovers nested skills under skills///', async () => { + const hermesPlugin = join(tmpDir, 'hermes-style-plugin'); + await mkdir(join(hermesPlugin, 'skills/research/llm-wiki'), { recursive: true }); + await mkdir(join(hermesPlugin, 'skills/dogfood'), { recursive: true }); + await writeFile(join(hermesPlugin, 'skills/research/llm-wiki/SKILL.md'), '# llm-wiki'); + await writeFile(join(hermesPlugin, 'skills/dogfood/SKILL.md'), '# dogfood'); + + const config = { + repositories: [], + plugins: [hermesPlugin], + clients: ['claude'], + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); + + const skills = await getAllSkillsFromPlugins(tmpDir); + const names = skills.map((s) => s.name).sort(); + expect(names).toEqual(['dogfood', 'llm-wiki']); + const llmWiki = skills.find((s) => s.name === 'llm-wiki'); + expect(llmWiki?.skillSubpath).toBe('research/llm-wiki'); + const dogfood = skills.find((s) => s.name === 'dogfood'); + expect(dogfood?.skillSubpath).toBe('dogfood'); + }); + + it('respects allowlist that uses a qualified subpath for a nested skill', async () => { + const hermesPlugin = join(tmpDir, 'hermes-style-plugin-2'); + await mkdir(join(hermesPlugin, 'skills/research/llm-wiki'), { recursive: true }); + await mkdir(join(hermesPlugin, 'skills/games/llm-wiki'), { recursive: true }); + await writeFile(join(hermesPlugin, 'skills/research/llm-wiki/SKILL.md'), '# research'); + await writeFile(join(hermesPlugin, 'skills/games/llm-wiki/SKILL.md'), '# games'); + + const config = { + repositories: [], + plugins: [{ source: hermesPlugin, skills: ['research/llm-wiki'] }], + clients: ['claude'], + version: 2, + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); + + const skills = await getAllSkillsFromPlugins(tmpDir); + const research = skills.find((s) => s.skillSubpath === 'research/llm-wiki'); + const games = skills.find((s) => s.skillSubpath === 'games/llm-wiki'); + expect(research?.disabled).toBe(false); + expect(games?.disabled).toBe(true); + }); + + it('omits directories under skills/ that lack a SKILL.md', async () => { + const plugin = join(tmpDir, 'fluff-plugin'); + await mkdir(join(plugin, 'skills/empty-category'), { recursive: true }); + await mkdir(join(plugin, 'skills/real-skill'), { recursive: true }); + await writeFile(join(plugin, 'skills/real-skill/SKILL.md'), '# real'); + + const config = { + repositories: [], + plugins: [plugin], + clients: ['claude'], + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); + + const skills = await getAllSkillsFromPlugins(tmpDir); + expect(skills.map((s) => s.name)).toEqual(['real-skill']); + }); + it('skips GitHub URL entries whose subpath no longer exists in cache', async () => { const originalHome = process.env.HOME; process.env.HOME = tmpDir; diff --git a/tests/unit/utils/plugin-path.test.ts b/tests/unit/utils/plugin-path.test.ts index 8619930..14b883b 100644 --- a/tests/unit/utils/plugin-path.test.ts +++ b/tests/unit/utils/plugin-path.test.ts @@ -35,6 +35,7 @@ const { getPluginCachePath, validatePluginSource, verifyGitHubUrlExists, + formatPluginSource, } = await import('../../../src/utils/plugin-path.js'); describe('isGitHubUrl', () => { @@ -326,6 +327,56 @@ describe('validatePluginSource', () => { }); }); +describe('formatPluginSource', () => { + it('shortens a bare GitHub HTTPS URL to owner/repo', () => { + expect(formatPluginSource('https://github.com/anthropics/claude-plugins-official')).toBe( + 'anthropics/claude-plugins-official', + ); + }); + + it('strips /blob/main and /tree/main while preserving subpath', () => { + expect(formatPluginSource('https://github.com/NousResearch/hermes-agent/blob/main/skills/research/llm-wiki')).toBe( + 'NousResearch/hermes-agent/skills/research/llm-wiki', + ); + expect(formatPluginSource('https://github.com/owner/repo/tree/master/plugins/foo')).toBe( + 'owner/repo/plugins/foo', + ); + }); + + it('keeps non-default branches with @/subpath', () => { + expect(formatPluginSource('https://github.com/owner/repo/blob/develop/skills/foo')).toBe( + 'owner/repo@develop/skills/foo', + ); + }); + + it('shortens gh: prefix to owner/repo', () => { + expect(formatPluginSource('gh:anthropics/claude-plugins-official')).toBe( + 'anthropics/claude-plugins-official', + ); + }); + + it('passes owner/repo shorthand through unchanged', () => { + expect(formatPluginSource('NousResearch/hermes-agent')).toBe('NousResearch/hermes-agent'); + }); + + it('preserves @ref on shorthand sources', () => { + expect(formatPluginSource('owner/repo@v1.2.0/sub')).toBe('owner/repo@v1.2.0/sub'); + }); + + it('leaves plugin@marketplace specs untouched', () => { + expect(formatPluginSource('superpowers@official')).toBe('superpowers@official'); + }); + + it('leaves local paths untouched', () => { + expect(formatPluginSource('./local-plugin')).toBe('./local-plugin'); + expect(formatPluginSource('/abs/path/to/plugin')).toBe('/abs/path/to/plugin'); + }); + + it('passes empty input through', () => { + expect(formatPluginSource('')).toBe(''); + }); +}); + describe('verifyGitHubUrlExists', () => { beforeEach(() => { repoExistsMock.mockClear();