Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion src/cli/commands/plugin-skills.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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<typeof parseGitHubUrl> } | 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<ReturnType<typeof parseGitHubUrl>>,
fallbackName: string,
): Promise<string> {
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
*/
Expand Down Expand Up @@ -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);

Expand Down
112 changes: 112 additions & 0 deletions tests/unit/cli/skills-add-url-detection.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});