diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 83dade0..4b1616d 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import chalk from 'chalk'; -import { command, positional, option, string, optional } from 'cmd-ts'; +import { command, positional, option, flag, string, optional } from 'cmd-ts'; import { syncWorkspace, syncUserWorkspace } from '../../core/sync.js'; import { addDisabledSkill, @@ -31,7 +31,7 @@ import { } from '../metadata/plugin-skills.js'; import { getHomeDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../constants.js'; import { isGitHubUrl, parseGitHubUrl } from '../../utils/plugin-path.js'; -import { fetchPlugin, getPluginName } from '../../core/plugin.js'; +import { fetchPlugin, getPluginName, seedFetchCache } from '../../core/plugin.js'; import { parseSkillMetadata } from '../../validators/skill.js'; import { addMarketplace, @@ -39,7 +39,9 @@ import { listMarketplacePlugins, updateMarketplace, } from '../../core/marketplace.js'; -import { parseMarketplaceManifest } from '../../utils/marketplace-manifest-parser.js'; +import { parseMarketplaceManifest, resolvePluginSourcePath } from '../../utils/marketplace-manifest-parser.js'; +import { formatSyncHeader, formatSyncSummary } from '../format-sync.js'; +import type { SyncResult } from '../../core/sync.js'; /** * Check if a directory has a project-level .allagents config @@ -607,6 +609,275 @@ async function applySkillAllowlist(opts: { }; } +// ============================================================================= +// Skill discovery helpers (for --list and --all) +// ============================================================================= + +interface DiscoveredSkill { + name: 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. + */ +export async function discoverSkillsWithMetadata( + pluginPath: string, + pluginName?: string, +): Promise { + const names = await discoverSkillNames(pluginPath); + const results: DiscoveredSkill[] = []; + + for (const name of names) { + const skillMdPath = resolveSkillMdPath(pluginPath, name); + let description = ''; + try { + const content = await readFile(skillMdPath, 'utf-8'); + const metadata = parseSkillMetadata(content); + description = metadata?.description ?? ''; + } catch { + // Leave description empty + } + results.push({ name, description, ...(pluginName && { pluginName }) }); + } + + return results; +} + +/** + * 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< + | { success: true; skills: DiscoveredSkill[]; isMarketplace: boolean } + | { success: false; error: string } +> { + const parsed = isGitHubUrl(from) ? parseGitHubUrl(from) : null; + const fetchResult = await fetchPlugin(from, { + ...(parsed?.branch && { branch: parsed.branch }), + }); + if (!fetchResult.success) { + return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` }; + } + + const manifestResult = await parseMarketplaceManifest(fetchResult.cachePath); + if (manifestResult.success) { + const all: DiscoveredSkill[] = []; + for (const plugin of manifestResult.data.plugins) { + // Skip remote URL sources — listing would need extra fetches + if (typeof plugin.source === 'object') continue; + const resolved = resolvePluginSourcePath(plugin.source, fetchResult.cachePath); + if (!existsSync(resolved)) continue; + const skills = await discoverSkillsWithMetadata(resolved, plugin.name); + all.push(...skills); + } + return { success: true, skills: all, isMarketplace: true }; + } + + const skills = await discoverSkillsWithMetadata(fetchResult.cachePath); + return { success: true, skills, isMarketplace: false }; +} + +/** + * Install all skills from a --from source. Mirrors installSkillFromSource but + * enables every discovered skill rather than a single named one. + */ +async function installAllSkillsFromSource(opts: { + from: string; + isUser: boolean; + workspacePath: string; +}): Promise< + | { success: true; installed: Array<{ pluginName: string; skills: string[] }>; syncResult: SyncResult } + | { success: false; error: string } +> { + const { from, isUser, workspacePath } = opts; + + if (!isJsonMode()) { + console.log(`Installing all skills from ${from}...`); + } + + const parsed = isGitHubUrl(from) ? parseGitHubUrl(from) : null; + const fetchResult = await fetchPlugin(from, { + ...(parsed?.branch && { branch: parsed.branch }), + }); + if (!fetchResult.success) { + return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` }; + } + + const manifestResult = await parseMarketplaceManifest(fetchResult.cachePath); + + if (manifestResult.success) { + return installAllViaMarketplace({ from, isUser, workspacePath, cachedPath: fetchResult.cachePath }); + } + + // Direct plugin install — enable every discovered skill + const skillNames = await discoverSkillNames(fetchResult.cachePath); + if (skillNames.length === 0) { + return { success: false, error: `No skills found in '${from}'.` }; + } + + 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 (!isJsonMode()) { + console.log('Plugin already installed.'); + } + } + + const pluginName = getPluginName(fetchResult.cachePath); + + const setModeResult = isUser + ? await setUserPluginSkillsMode(pluginName, 'allowlist', skillNames) + : await setPluginSkillsMode(pluginName, 'allowlist', skillNames, workspacePath); + + if (!setModeResult.success) { + 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('\nSyncing workspace...\n'); + } + + const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + if (!syncResult.success) { + return { success: false, error: 'Sync failed' }; + } + + return { + success: true, + installed: [{ pluginName, skills: skillNames }], + syncResult, + }; +} + +/** + * Install every plugin from a marketplace source and enable every skill in each. + */ +async function installAllViaMarketplace(opts: { + from: string; + isUser: boolean; + workspacePath: string; + cachedPath?: string; +}): Promise< + | { success: true; installed: Array<{ pluginName: string; skills: string[] }>; syncResult: SyncResult } + | { success: false; error: string } +> { + const { from, isUser, workspacePath, cachedPath } = opts; + const parsed = isGitHubUrl(from) ? parseGitHubUrl(from) : null; + const sourceLocation = parsed ? `${parsed.owner}/${parsed.repo}` : undefined; + + let marketplaceName: string | undefined; + const existingAnyScope = await findMarketplace( + parsed?.repo ?? from, + sourceLocation, + isUser ? undefined : workspacePath, + ); + + if (existingAnyScope) { + marketplaceName = existingAnyScope.name; + 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. + if (cachedPath) seedFetchCache(from, cachedPath); + + const scopeOptions = isUser + ? undefined + : { scope: 'project' as const, workspacePath }; + + const mktResult = await addMarketplace( + from, + parsed?.branch ? `${parsed.repo}-${parsed.branch}` : undefined, + parsed?.branch ?? undefined, + undefined, + scopeOptions, + ); + + if (mktResult.success) { + marketplaceName = mktResult.marketplace?.name; + } + } + + if (!marketplaceName) { + return { success: false, error: `Failed to register marketplace from '${from}'` }; + } + + const mktPlugins = await listMarketplacePlugins(marketplaceName, isUser ? undefined : workspacePath); + if (mktPlugins.plugins.length === 0) { + return { success: false, error: `No plugins found in marketplace '${marketplaceName}'.` }; + } + + const installed: Array<{ pluginName: string; skills: string[] }> = []; + + for (const mktPlugin of mktPlugins.plugins) { + const skillNames = mktPlugin.skills + ? mktPlugin.skills.map((s) => s.split('/').pop() ?? '').filter(Boolean) + : await discoverSkillNames(mktPlugin.path); + + if (skillNames.length === 0) continue; + + const pluginSpec = `${mktPlugin.name}@${marketplaceName}`; + const installResult = isUser + ? await addUserPlugin(pluginSpec) + : 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'}` }; + } + } + + const setModeResult = isUser + ? await setUserPluginSkillsMode(mktPlugin.name, 'allowlist', skillNames) + : 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'}` }; + } + + installed.push({ pluginName: mktPlugin.name, skills: skillNames }); + } + + if (installed.length === 0) { + 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('\nSyncing workspace...\n'); + } + + const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath); + if (!syncResult.success) { + return { success: false, error: 'Sync failed' }; + } + + return { + success: true, + installed, + syncResult, + }; +} + // ============================================================================= // plugin skills add // ============================================================================= @@ -615,7 +886,7 @@ const addCmd = command({ name: 'add', description: buildDescription(skillsAddMeta), args: { - skill: positional({ type: string, displayName: 'skill' }), + skill: positional({ type: optional(string), displayName: 'skill' }), scope: option({ type: optional(string), long: 'scope', @@ -634,9 +905,169 @@ const addCmd = command({ short: 'f', description: 'Plugin source to install if the skill is not already available', }), + list: flag({ + long: 'list', + short: 'l', + description: 'List available skills at --from without installing', + }), + all: flag({ + long: 'all', + description: 'Install every skill from --from', + }), }, - handler: async ({ skill: skillArg, scope, plugin, from: fromArg }) => { + handler: async ({ skill: skillArg, scope, plugin, from: fromArg, list, all }) => { try { + // --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.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + if (!fromArg) { + const error = '--list requires --from to specify a plugin source.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + if (all) { + const error = '--list and --all cannot be used together.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + + const discovered = await discoverSkillsFromSource(fromArg); + if (!discovered.success) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error: discovered.error }); + process.exit(1); + } + console.error(`Error: ${discovered.error}`); + process.exit(1); + } + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'plugin skills add', + data: { + source: fromArg, + isMarketplace: discovered.isMarketplace, + skills: discovered.skills.map((s) => ({ + name: s.name, + description: s.description, + ...(s.pluginName && { plugin: s.pluginName }), + })), + }, + }); + return; + } + + if (discovered.skills.length === 0) { + console.log(`No skills found in ${fromArg}.`); + return; + } + + 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; + console.log(` ${chalk.hex('#89b4fa')(label)}`); + if (s.description) console.log(` ${s.description}`); + console.log(); + } + return; + } + + // --all: bulk install + if (all) { + if (!fromArg) { + const error = '--all requires --from to specify a plugin source.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + if (skillArg) { + const error = 'Cannot combine a skill argument with --all. Use --all alone to install every skill.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + + const isUserAll = scope === 'user'; + const workspacePathAll = isUserAll ? getHomeDir() : process.cwd(); + + const installResult = await installAllSkillsFromSource({ + from: fromArg, + isUser: isUserAll, + workspacePath: workspacePathAll, + }); + + if (!installResult.success) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error: installResult.error }); + process.exit(1); + } + console.error(`Error: ${installResult.error}`); + process.exit(1); + } + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'plugin skills add', + data: { + source: fromArg, + installed: installResult.installed, + syncResult: { + copied: installResult.syncResult.totalCopied, + failed: installResult.syncResult.totalFailed, + }, + }, + }); + return; + } + + for (const line of formatSyncHeader(installResult.syncResult)) { + console.log(line); + } + const summaryLines = formatSyncSummary(installResult.syncResult); + if (summaryLines.length > 0) { + console.log(''); + for (const line of summaryLines) { + console.log(line); + } + } + 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.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + let skill = skillArg; let from = fromArg; diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index 523ec7a..e5658d0 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -51,14 +51,16 @@ export const skillsAddMeta: AgentCommandMeta = { 'allagents skills add https://github.com/owner/repo/tree/main/skills/my-skill', 'allagents skills add brainstorming', 'allagents skills add brainstorming --plugin superpowers', + 'allagents skills add --list --from rstackjs/agent-skills', + 'allagents skills add --all --from rstackjs/agent-skills', ], expectedOutput: 'Confirms skill was enabled and runs sync', positionals: [ { name: 'skill', type: 'string', - required: true, - description: 'Skill name to add, or a GitHub URL pointing to a skill', + required: false, + description: 'Skill name to add, or a GitHub URL pointing to a skill. Omit with --list or --all.', }, ], options: [ @@ -76,6 +78,17 @@ export const skillsAddMeta: AgentCommandMeta = { description: 'Plugin source (GitHub URL, owner/repo, or plugin@marketplace) to install if the skill is not already available', }, + { + flag: '--list', + short: '-l', + type: 'boolean', + description: 'List skills at the --from source without installing', + }, + { + flag: '--all', + type: 'boolean', + description: 'Install every skill from the --from source', + }, ], outputSchema: { skill: 'string', diff --git a/tests/unit/cli/skills-add-list-all.test.ts b/tests/unit/cli/skills-add-list-all.test.ts new file mode 100644 index 0000000..f6a33a0 --- /dev/null +++ b/tests/unit/cli/skills-add-list-all.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { discoverSkillsWithMetadata } from '../../../src/cli/commands/plugin-skills.js'; + +describe('discoverSkillsWithMetadata', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'allagents-discover-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns skills with descriptions from standard skills/ layout', async () => { + await mkdir(join(tmpDir, 'skills/alpha'), { recursive: true }); + await mkdir(join(tmpDir, 'skills/beta'), { recursive: true }); + await writeFile( + join(tmpDir, 'skills/alpha/SKILL.md'), + '---\nname: alpha\ndescription: First skill\n---\n# Alpha\n', + ); + await writeFile( + join(tmpDir, 'skills/beta/SKILL.md'), + '---\nname: beta\ndescription: Second skill\n---\n# Beta\n', + ); + + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result).toHaveLength(2); + const alpha = result.find((s) => s.name === 'alpha'); + const beta = result.find((s) => s.name === 'beta'); + expect(alpha?.description).toBe('First skill'); + expect(beta?.description).toBe('Second skill'); + }); + + it('returns skills from flat layout (no skills/ dir)', async () => { + await mkdir(join(tmpDir, 'gamma'), { recursive: true }); + await writeFile( + join(tmpDir, 'gamma/SKILL.md'), + '---\nname: gamma\ndescription: Flat skill\n---\n', + ); + + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('gamma'); + expect(result[0]?.description).toBe('Flat skill'); + }); + + it('returns empty description when SKILL.md frontmatter is missing description', async () => { + await mkdir(join(tmpDir, 'skills/delta'), { recursive: true }); + await writeFile(join(tmpDir, 'skills/delta/SKILL.md'), '# Just a heading, no frontmatter\n'); + + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('delta'); + expect(result[0]?.description).toBe(''); + }); + + it('attaches pluginName when provided', async () => { + await mkdir(join(tmpDir, 'skills/epsilon'), { recursive: true }); + await writeFile( + join(tmpDir, 'skills/epsilon/SKILL.md'), + '---\nname: epsilon\ndescription: Skill in plugin\n---\n', + ); + + const result = await discoverSkillsWithMetadata(tmpDir, 'my-plugin'); + expect(result[0]?.pluginName).toBe('my-plugin'); + }); + + it('returns an empty array for an empty directory', async () => { + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result).toEqual([]); + }); + + it('falls back to root SKILL.md when skill has no skills/ or flat-layout dir', async () => { + // Root-layout: the repo itself is a single skill, no skills/ subdir + await writeFile( + join(tmpDir, 'SKILL.md'), + '---\nname: root-skill\ndescription: Root layout skill\n---\n# Root Skill\n', + ); + + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('root-skill'); + 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 = '' + await mkdir(join(tmpDir, 'skills/eta'), { recursive: true }); + // Intentionally no SKILL.md written + + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('eta'); + expect(result[0]?.description).toBe(''); + }); + + it('returns pluginName: undefined when not provided', async () => { + await mkdir(join(tmpDir, 'skills/theta'), { recursive: true }); + await writeFile( + join(tmpDir, 'skills/theta/SKILL.md'), + '---\nname: theta\ndescription: No plugin context\n---\n', + ); + + const result = await discoverSkillsWithMetadata(tmpDir); + expect(result[0]?.pluginName).toBeUndefined(); + }); +});