diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 41b19d2..a719f46 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -1,4 +1,5 @@ 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'; @@ -27,6 +28,9 @@ import { skillsAddMeta, } 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 } from '../../core/plugin.js'; +import { parseSkillMetadata } from '../../validators/skill.js'; /** * Check if a directory has a project-level .allagents config @@ -45,6 +49,54 @@ function resolveScope(cwd: string): 'user' | 'project' { return 'user'; } +/** + * If the skill argument is a GitHub URL, extract the skill name and return + * it along with the URL as the plugin source. Returns null if not a URL. + * + * With subpath: skill name = last path segment + * Without subpath: skill name = repo name (caller should use resolveSkillNameFromRepo to check frontmatter) + */ +export function resolveSkillFromUrl( + skill: string, +): { skill: string; from: string; parsed: ReturnType } | null { + if (!isGitHubUrl(skill)) return null; + + const parsed = parseGitHubUrl(skill); + if (!parsed) return null; + + if (parsed.subpath) { + const segments = parsed.subpath.split('/').filter(Boolean); + const name = segments[segments.length - 1]; + if (!name) return null; + return { skill: name, from: skill, parsed }; + } + + return { skill: parsed.repo, from: skill, parsed }; +} + +/** + * For a no-subpath GitHub URL, fetch the repo and read SKILL.md frontmatter + * to get the real skill name. Falls back to the provided default name. + */ +export async function resolveSkillNameFromRepo( + url: string, + parsed: NonNullable>, + fallbackName: string, +): Promise { + const fetchResult = await fetchPlugin(url, { + ...(parsed.branch && { branch: parsed.branch }), + }); + if (!fetchResult.success) return fallbackName; + + try { + const skillMd = await readFile(join(fetchResult.cachePath, 'SKILL.md'), 'utf-8'); + const metadata = parseSkillMetadata(skillMd); + return metadata?.name ?? fallbackName; + } catch { + return fallbackName; + } +} + /** * Group skills by plugin for display */ @@ -333,11 +385,37 @@ const addCmd = command({ description: 'Plugin source to install if the skill is not already available', }), }, - handler: async ({ skill, scope, plugin, from }) => { + handler: async ({ skill: skillArg, scope, plugin, from: fromArg }) => { try { + let skill = skillArg; + let from = fromArg; + const isUser = scope === 'user' || (!scope && resolveScope(process.cwd()) === 'user'); const workspacePath = isUser ? getHomeDir() : process.cwd(); + // Auto-detect GitHub URL as skill argument + const urlResolved = resolveSkillFromUrl(skill); + if (urlResolved) { + if (from) { + const error = + 'Cannot use --from when the skill argument is a GitHub URL. The URL is used as the plugin source automatically.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin skills add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + from = urlResolved.from; + + // 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); + } else { + skill = urlResolved.skill; + } + } + // Find the skill let matches = await findSkillByName(skill, workspacePath); diff --git a/tests/unit/cli/skills-add-url-detection.test.ts b/tests/unit/cli/skills-add-url-detection.test.ts new file mode 100644 index 0000000..85e4047 --- /dev/null +++ b/tests/unit/cli/skills-add-url-detection.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, mock } from 'bun:test'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveSkillFromUrl } from '../../../src/cli/commands/plugin-skills.js'; + +describe('resolveSkillFromUrl', () => { + it('returns null for non-URL skill names', () => { + expect(resolveSkillFromUrl('my-skill')).toBeNull(); + }); + + it('extracts skill name from URL with subpath', () => { + const result = resolveSkillFromUrl( + 'https://github.com/anthropics/skills/tree/main/skills/skill-creator', + ); + expect(result).toEqual({ + skill: 'skill-creator', + from: 'https://github.com/anthropics/skills/tree/main/skills/skill-creator', + parsed: expect.objectContaining({ owner: 'anthropics', repo: 'skills', subpath: 'skills/skill-creator' }), + }); + }); + + it('extracts skill name from URL with deep subpath', () => { + const result = resolveSkillFromUrl( + 'https://github.com/org/repo/tree/main/plugins/my-plugin/skills/cool-skill', + ); + expect(result?.skill).toBe('cool-skill'); + }); + + it('falls back to repo name for URL without subpath', () => { + const result = resolveSkillFromUrl('https://github.com/owner/my-skill-repo'); + expect(result).toEqual({ + skill: 'my-skill-repo', + from: 'https://github.com/owner/my-skill-repo', + parsed: expect.objectContaining({ owner: 'owner', repo: 'my-skill-repo' }), + }); + }); + + it('works with owner/repo/subpath shorthand', () => { + const result = resolveSkillFromUrl('anthropics/skills/skills/skill-creator'); + expect(result?.skill).toBe('skill-creator'); + expect(result?.from).toBe('anthropics/skills/skills/skill-creator'); + }); + + it('falls back to repo name for gh: shorthand without tree path', () => { + const result = resolveSkillFromUrl('gh:owner/my-skill-repo'); + expect(result?.skill).toBe('my-skill-repo'); + expect(result?.from).toBe('gh:owner/my-skill-repo'); + }); +}); + +describe('resolveSkillNameFromRepo', () => { + it('returns frontmatter name when SKILL.md exists with name', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'skill-test-')); + try { + await writeFile( + join(tmpDir, 'SKILL.md'), + '---\nname: my-awesome-skill\ndescription: A test skill\n---\n# Skill\n', + ); + + mock.module('../../../src/core/plugin.js', () => ({ + fetchPlugin: async () => ({ success: true, action: 'fetched' as const, cachePath: tmpDir }), + getPluginName: () => 'test-plugin', + })); + + const { resolveSkillNameFromRepo } = await import('../../../src/cli/commands/plugin-skills.js'); + const result = await resolveSkillNameFromRepo( + 'https://github.com/owner/repo', + { owner: 'owner', repo: 'repo' }, + 'fallback-name', + ); + expect(result).toBe('my-awesome-skill'); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns fallback name when SKILL.md does not exist', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'skill-test-')); + try { + mock.module('../../../src/core/plugin.js', () => ({ + fetchPlugin: async () => ({ success: true, action: 'fetched' as const, cachePath: tmpDir }), + getPluginName: () => 'test-plugin', + })); + + const { resolveSkillNameFromRepo } = await import('../../../src/cli/commands/plugin-skills.js'); + const result = await resolveSkillNameFromRepo( + 'https://github.com/owner/repo', + { owner: 'owner', repo: 'repo' }, + 'fallback-name', + ); + expect(result).toBe('fallback-name'); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns fallback name when fetchPlugin fails', async () => { + mock.module('../../../src/core/plugin.js', () => ({ + fetchPlugin: async () => ({ success: false, action: 'skipped' as const, cachePath: '', error: 'network error' }), + getPluginName: () => 'test-plugin', + })); + + const { resolveSkillNameFromRepo } = await import('../../../src/cli/commands/plugin-skills.js'); + const result = await resolveSkillNameFromRepo( + 'https://github.com/owner/repo', + { owner: 'owner', repo: 'repo' }, + 'fallback-name', + ); + expect(result).toBe('fallback-name'); + }); +});