diff --git a/src/commands/install.ts b/src/commands/install.ts index b4c015e..2dfc3a7 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -9,6 +9,7 @@ import { ExitPromptError } from '@inquirer/core'; import { hasValidFrontmatter, extractYamlField } from '../utils/yaml.js'; import { ANTHROPIC_MARKETPLACE_SKILLS } from '../utils/marketplace-skills.js'; import { writeSkillMetadata } from '../utils/skill-metadata.js'; +import { readSkillJson, getResolvedSkillEntries } from '../utils/skill-json.js'; import type { InstallOptions } from '../types.js'; import type { SkillSourceMetadata, SkillSourceType } from '../utils/skill-metadata.js'; @@ -317,6 +318,11 @@ async function installSpecificSkill( /** * Install from repository (with interactive selection unless -y flag) + * + * Discovery order: + * 1. skill.json at repo root (richer metadata, no filesystem scan) + * 2. SKILL.md at repo root (single-skill repo) + * 3. Recursive SKILL.md scan (legacy fallback) */ async function installFromRepo( repoDir: string, @@ -325,113 +331,130 @@ async function installFromRepo( repoName: string | undefined, sourceInfo: InstallSourceInfo ): Promise { - const rootSkillPath = join(repoDir, 'SKILL.md'); let skillInfos: Array<{ skillDir: string; skillName: string; description: string; targetPath: string; size: number; + category?: string; + tags?: string[]; }> = []; - if (existsSync(rootSkillPath)) { - const content = readFileSync(rootSkillPath, 'utf-8'); - if (!hasValidFrontmatter(content)) { - console.error(chalk.red('Error: Invalid SKILL.md (missing YAML frontmatter)')); + // --- 1. skill.json discovery --- + const skillJson = readSkillJson(repoDir); + if (skillJson) { + const entries = getResolvedSkillEntries(repoDir, skillJson); + + if (entries.length === 0) { + console.error(chalk.red('Error: skill.json found but no skill directories resolved on disk')); process.exit(1); } - const frontmatterName = extractYamlField(content, 'name'); - const skillName = frontmatterName || repoName || basename(repoDir); - skillInfos = [ - { - skillDir: repoDir, - skillName, - description: extractYamlField(content, 'description'), - targetPath: join(targetDir, skillName), - size: getDirectorySize(repoDir), - }, - ]; - } - - // Find all skills - const findSkills = (dir: string): string[] => { - const skills: string[] = []; - const entries = readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - if (existsSync(join(fullPath, 'SKILL.md'))) { - skills.push(fullPath); - } else { - skills.push(...findSkills(fullPath)); - } + skillInfos = entries.map((entry) => ({ + skillDir: entry.resolvedPath, + skillName: entry.name, + description: entry.description ?? '', + targetPath: join(targetDir, entry.name), + size: getDirectorySize(entry.resolvedPath), + category: entry.category, + tags: entry.tags, + })); + + console.log(chalk.dim(`Found ${skillInfos.length} skill(s) via skill.json\n`)); + } else { + // --- 2. Root SKILL.md (single-skill repo) --- + const rootSkillPath = join(repoDir, 'SKILL.md'); + if (existsSync(rootSkillPath)) { + const content = readFileSync(rootSkillPath, 'utf-8'); + if (!hasValidFrontmatter(content)) { + console.error(chalk.red('Error: Invalid SKILL.md (missing YAML frontmatter)')); + process.exit(1); } + + const frontmatterName = extractYamlField(content, 'name'); + const skillName = frontmatterName || repoName || basename(repoDir); + skillInfos = [ + { + skillDir: repoDir, + skillName, + description: extractYamlField(content, 'description'), + targetPath: join(targetDir, skillName), + size: getDirectorySize(repoDir), + }, + ]; } - return skills; - }; - if (skillInfos.length === 0) { - const skillDirs = findSkills(repoDir); + // --- 3. Recursive SKILL.md scan (legacy fallback) --- + if (skillInfos.length === 0) { + const findSkills = (dir: string): string[] => { + const skills: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (existsSync(join(fullPath, 'SKILL.md'))) { + skills.push(fullPath); + } else { + skills.push(...findSkills(fullPath)); + } + } + } + return skills; + }; - if (skillDirs.length === 0) { - console.error(chalk.red('Error: No SKILL.md files found in repository')); - process.exit(1); - } + const skillDirs = findSkills(repoDir); - // Build skill info list - skillInfos = skillDirs - .map((skillDir) => { - const skillMdPath = join(skillDir, 'SKILL.md'); - const content = readFileSync(skillMdPath, 'utf-8'); + if (skillDirs.length === 0) { + console.error(chalk.red('Error: No SKILL.md files found in repository')); + process.exit(1); + } - if (!hasValidFrontmatter(content)) { - return null; - } + // Build skill info list + skillInfos = skillDirs + .map((skillDir) => { + const skillMdPath = join(skillDir, 'SKILL.md'); + const content = readFileSync(skillMdPath, 'utf-8'); - const skillName = basename(skillDir); - const description = extractYamlField(content, 'description'); - const targetPath = join(targetDir, skillName); + if (!hasValidFrontmatter(content)) { + return null; + } - // Get size - const size = getDirectorySize(skillDir); + const skillName = basename(skillDir); + const description = extractYamlField(content, 'description'); + const targetPath = join(targetDir, skillName); - return { - skillDir, - skillName, - description, - targetPath, - size, - }; - }) - .filter((info) => info !== null) as Array<{ - skillDir: string; - skillName: string; - description: string; - targetPath: string; - size: number; - }>; + // Get size + const size = getDirectorySize(skillDir); - if (skillInfos.length === 0) { - console.error(chalk.red('Error: No valid SKILL.md files found')); - process.exit(1); + return { skillDir, skillName, description, targetPath, size }; + }) + .filter((info) => info !== null) as typeof skillInfos; + + if (skillInfos.length === 0) { + console.error(chalk.red('Error: No valid SKILL.md files found')); + process.exit(1); + } + + console.log(chalk.dim(`Found ${skillInfos.length} skill(s)\n`)); } } - console.log(chalk.dim(`Found ${skillInfos.length} skill(s)\n`)); - // Interactive selection (unless -y flag or single skill) let skillsToInstall = skillInfos; if (!options.yes && skillInfos.length > 1) { try { - const choices = skillInfos.map((info) => ({ - name: `${chalk.bold(info.skillName.padEnd(25))} ${chalk.dim(formatSize(info.size))}`, - value: info.skillName, - description: info.description.slice(0, 80), - checked: true, // Check all by default - })); + const choices = skillInfos.map((info) => { + const categoryTag = info.category ? chalk.cyan(` [${info.category}]`) : ''; + return { + name: `${chalk.bold(info.skillName.padEnd(25))} ${chalk.dim(formatSize(info.size))}${categoryTag}`, + value: info.skillName, + description: info.description.slice(0, 80), + checked: true, // Check all by default + }; + }); const selected = await checkbox({ message: 'Select skills to install', @@ -473,7 +496,13 @@ async function installFromRepo( continue; } cpSync(info.skillDir, info.targetPath, { recursive: true, dereference: true }); - writeSkillMetadata(info.targetPath, buildMetadataFromSource(sourceInfo, info.skillDir, repoDir)); + writeSkillMetadata( + info.targetPath, + buildMetadataFromSource(sourceInfo, info.skillDir, repoDir, { + category: info.category, + tags: info.tags, + }) + ); console.log(chalk.green(`āœ… Installed: ${info.skillName}`)); installedCount++; @@ -482,35 +511,52 @@ async function installFromRepo( console.log(chalk.green(`\nāœ… Installation complete: ${installedCount} skill(s) installed`)); } +interface SkillJsonExtras { + category?: string; + tags?: string[]; + version?: string; +} + function buildMetadataFromSource( sourceInfo: InstallSourceInfo, skillDir: string, - repoDir: string + repoDir: string, + extras: SkillJsonExtras = {} ): SkillSourceMetadata { if (sourceInfo.sourceType === 'local') { - return buildLocalMetadata(sourceInfo, skillDir); + return buildLocalMetadata(sourceInfo, skillDir, extras); } const subpath = relative(repoDir, skillDir); const normalizedSubpath = subpath === '' ? '' : subpath; - return buildGitMetadata(sourceInfo, normalizedSubpath); + return buildGitMetadata(sourceInfo, normalizedSubpath, extras); } -function buildGitMetadata(sourceInfo: InstallSourceInfo, subpath: string): SkillSourceMetadata { +function buildGitMetadata( + sourceInfo: InstallSourceInfo, + subpath: string, + extras: SkillJsonExtras = {} +): SkillSourceMetadata { return { source: sourceInfo.source, sourceType: 'git', repoUrl: sourceInfo.repoUrl, subpath, installedAt: new Date().toISOString(), + ...extras, }; } -function buildLocalMetadata(sourceInfo: InstallSourceInfo, skillDir: string): SkillSourceMetadata { +function buildLocalMetadata( + sourceInfo: InstallSourceInfo, + skillDir: string, + extras: SkillJsonExtras = {} +): SkillSourceMetadata { return { source: sourceInfo.source, sourceType: 'local', localPath: skillDir, installedAt: new Date().toISOString(), + ...extras, }; } diff --git a/src/types.ts b/src/types.ts index f0d0040..a1e9328 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,35 @@ export interface Skill { description: string; location: 'project' | 'global'; path: string; + category?: string; + tags?: string[]; + version?: string; +} + +/** + * A single skill entry within a skill.json index file + */ +export interface SkillJsonEntry { + name: string; + path: string; + description: string; + category?: string; + tags?: string[]; + integrity?: string; + requires?: { + tools?: string[]; + [key: string]: unknown; + }; +} + +/** + * The top-level shape of a skill.json file in a source repo + */ +export interface SkillJson { + name?: string; + version?: string; + description?: string; + skills: SkillJsonEntry[]; } export interface SkillLocation { diff --git a/src/utils/agents-md.ts b/src/utils/agents-md.ts index 136bc86..3e21500 100644 --- a/src/utils/agents-md.ts +++ b/src/utils/agents-md.ts @@ -22,13 +22,18 @@ export function parseCurrentSkills(content: string): string[] { */ export function generateSkillsXml(skills: Skill[]): string { const skillTags = skills - .map( - (s) => ` -${s.name} -${s.description} -${s.location} -` - ) + .map((s) => { + const lines = [ + ``, + `${s.name}`, + `${s.description}`, + `${s.location}`, + ]; + if (s.category) lines.push(`${s.category}`); + if (s.tags && s.tags.length > 0) lines.push(`${s.tags.join(', ')}`); + lines.push(``); + return lines.join('\n'); + }) .join('\n\n'); return ` diff --git a/src/utils/skill-json.ts b/src/utils/skill-json.ts new file mode 100644 index 0000000..57c6529 --- /dev/null +++ b/src/utils/skill-json.ts @@ -0,0 +1,79 @@ +import { existsSync, readFileSync } from 'fs'; +import { join, resolve } from 'path'; +import type { SkillJson, SkillJsonEntry } from '../types.js'; + +export const SKILL_JSON_FILENAME = 'skill.json'; + +/** + * Read and parse a skill.json file from a directory. + * Returns null if the file does not exist or is invalid JSON. + */ +export function readSkillJson(repoDir: string): SkillJson | null { + const skillJsonPath = join(repoDir, SKILL_JSON_FILENAME); + if (!existsSync(skillJsonPath)) return null; + + try { + const raw = readFileSync(skillJsonPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + + if (!isValidSkillJson(parsed)) { + return null; + } + + return parsed; + } catch { + return null; + } +} + +/** + * Type guard: ensure the parsed JSON conforms to the SkillJson shape. + */ +function isValidSkillJson(value: unknown): value is SkillJson { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + + if (!Array.isArray(obj['skills'])) return false; + + for (const entry of obj['skills'] as unknown[]) { + if (!isValidSkillJsonEntry(entry)) return false; + } + + return true; +} + +function isValidSkillJsonEntry(value: unknown): value is SkillJsonEntry { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return typeof obj['name'] === 'string' && typeof obj['path'] === 'string'; +} + +/** + * Resolve a skill entry's path relative to the repo directory. + * Normalises "./foo" → "/foo". + */ +export function resolveSkillEntryPath(repoDir: string, entryPath: string): string { + // If path starts with ./ or ../ treat it as relative to repoDir + if (entryPath.startsWith('./') || entryPath.startsWith('../')) { + return resolve(repoDir, entryPath); + } + // Otherwise join directly (handles bare names like "pdf") + return join(repoDir, entryPath); +} + +/** + * Extract a flat list of skill entries from a skill.json, resolving their + * absolute paths. Entries whose resolved paths do not exist on disk are + * silently excluded (the directory may not be present in a shallow clone, etc.). + */ +export function getResolvedSkillEntries( + repoDir: string, + skillJson: SkillJson +): Array { + return skillJson.skills + .map((entry) => ({ + ...entry, + resolvedPath: resolveSkillEntryPath(repoDir, entry.path), + })) + .filter((entry) => existsSync(entry.resolvedPath)); +} diff --git a/src/utils/skill-metadata.ts b/src/utils/skill-metadata.ts index bf720b3..d1852ca 100644 --- a/src/utils/skill-metadata.ts +++ b/src/utils/skill-metadata.ts @@ -12,6 +12,10 @@ export interface SkillSourceMetadata { subpath?: string; localPath?: string; installedAt: string; + /** Optional metadata sourced from skill.json at install time */ + category?: string; + tags?: string[]; + version?: string; } export function readSkillMetadata(skillDir: string): SkillSourceMetadata | null { diff --git a/src/utils/skills.ts b/src/utils/skills.ts index d3c8253..a5cbb33 100644 --- a/src/utils/skills.ts +++ b/src/utils/skills.ts @@ -2,6 +2,7 @@ import { readFileSync, readdirSync, existsSync, statSync, Dirent } from 'fs'; import { join } from 'path'; import { getSearchDirs } from './dirs.js'; import { extractYamlField } from './yaml.js'; +import { readSkillMetadata } from './skill-metadata.js'; import type { Skill, SkillLocation } from '../types.js'; /** @@ -47,11 +48,17 @@ export function findAllSkills(): Skill[] { const content = readFileSync(skillPath, 'utf-8'); const isProjectLocal = dir.includes(process.cwd()); + const skillDir = join(dir, entry.name); + const meta = readSkillMetadata(skillDir); + skills.push({ name: entry.name, description: extractYamlField(content, 'description'), location: isProjectLocal ? 'project' : 'global', - path: join(dir, entry.name), + path: skillDir, + category: meta?.category, + tags: meta?.tags, + version: meta?.version, }); seen.add(entry.name); diff --git a/tests/commands/sync.test.ts b/tests/commands/sync.test.ts index a6ebc70..7c469ff 100644 --- a/tests/commands/sync.test.ts +++ b/tests/commands/sync.test.ts @@ -53,6 +53,49 @@ describe('sync utilities (agents-md.ts)', () => { expect(xml).toContain(''); expect(xml).toContain(''); }); + + it('should include category when present on a skill', () => { + const skills: Skill[] = [ + { + name: 'pdf', + description: 'PDF manipulation', + location: 'project', + path: '/path/to/pdf', + category: 'documents', + }, + ]; + + const xml = generateSkillsXml(skills); + + expect(xml).toContain('documents'); + }); + + it('should include tags when present on a skill', () => { + const skills: Skill[] = [ + { + name: 'pdf', + description: 'PDF manipulation', + location: 'project', + path: '/path/to/pdf', + tags: ['pdf', 'ocr', 'merge'], + }, + ]; + + const xml = generateSkillsXml(skills); + + expect(xml).toContain('pdf, ocr, merge'); + }); + + it('should omit category/tags elements when not present', () => { + const skills: Skill[] = [ + { name: 'pdf', description: 'PDF manipulation', location: 'project', path: '/path' }, + ]; + + const xml = generateSkillsXml(skills); + + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + }); }); describe('parseCurrentSkills', () => { diff --git a/tests/utils/skill-json.test.ts b/tests/utils/skill-json.test.ts new file mode 100644 index 0000000..15ba465 --- /dev/null +++ b/tests/utils/skill-json.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { + readSkillJson, + resolveSkillEntryPath, + getResolvedSkillEntries, +} from '../../src/utils/skill-json.js'; + +const testId = Math.random().toString(36).slice(2); +const testTempDir = join(tmpdir(), `openskills-skill-json-test-${testId}`); + +function writeJson(dir: string, filename: string, data: unknown): void { + writeFileSync(join(dir, filename), JSON.stringify(data, null, 2)); +} + +describe('skill-json.ts', () => { + beforeEach(() => { + mkdirSync(testTempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testTempDir, { recursive: true, force: true }); + }); + + // --------------------------------------------------------------------------- + // readSkillJson + // --------------------------------------------------------------------------- + describe('readSkillJson', () => { + it('should return null when skill.json does not exist', () => { + const result = readSkillJson(testTempDir); + expect(result).toBeNull(); + }); + + it('should return null for invalid JSON', () => { + writeFileSync(join(testTempDir, 'skill.json'), 'not valid json {{'); + const result = readSkillJson(testTempDir); + expect(result).toBeNull(); + }); + + it('should return null when skills array is missing', () => { + writeJson(testTempDir, 'skill.json', { name: 'repo', version: '1.0.0' }); + const result = readSkillJson(testTempDir); + expect(result).toBeNull(); + }); + + it('should return null when skills array contains entries missing required fields', () => { + writeJson(testTempDir, 'skill.json', { + skills: [{ name: 'pdf' }], // missing 'path' + }); + const result = readSkillJson(testTempDir); + expect(result).toBeNull(); + }); + + it('should return null when skills array contains entries with non-string name', () => { + writeJson(testTempDir, 'skill.json', { + skills: [{ name: 42, path: './pdf' }], + }); + const result = readSkillJson(testTempDir); + expect(result).toBeNull(); + }); + + it('should return null when skills is not an array', () => { + writeJson(testTempDir, 'skill.json', { skills: 'not-an-array' }); + const result = readSkillJson(testTempDir); + expect(result).toBeNull(); + }); + + it('should parse a minimal valid skill.json', () => { + writeJson(testTempDir, 'skill.json', { + skills: [{ name: 'pdf', path: './pdf' }], + }); + const result = readSkillJson(testTempDir); + expect(result).not.toBeNull(); + expect(result?.skills).toHaveLength(1); + expect(result?.skills[0].name).toBe('pdf'); + expect(result?.skills[0].path).toBe('./pdf'); + }); + + it('should parse a full-featured skill.json', () => { + const data = { + name: 'anthropic-skills', + version: '2.1.0', + description: 'Official AI agent skills from Anthropic', + skills: [ + { + name: 'pdf', + path: './pdf', + description: 'Read, create, merge, split, and OCR PDF files', + category: 'documents', + tags: ['pdf', 'ocr', 'merge'], + integrity: 'sha256-yY1jg1cPGoisxK/ed7yMxPeDkU8UL7pHhPAqIci0wRA=', + requires: { tools: ['python3'] }, + }, + { + name: 'skill-creator', + path: './skill-creator', + description: 'Create and iterate on new agent skills with evals', + category: 'development', + tags: ['meta', 'skill', 'eval'], + }, + ], + }; + writeJson(testTempDir, 'skill.json', data); + + const result = readSkillJson(testTempDir); + expect(result).not.toBeNull(); + expect(result?.name).toBe('anthropic-skills'); + expect(result?.version).toBe('2.1.0'); + expect(result?.skills).toHaveLength(2); + + const pdf = result?.skills[0]; + expect(pdf?.name).toBe('pdf'); + expect(pdf?.category).toBe('documents'); + expect(pdf?.tags).toEqual(['pdf', 'ocr', 'merge']); + expect(pdf?.requires?.tools).toEqual(['python3']); + + const skillCreator = result?.skills[1]; + expect(skillCreator?.name).toBe('skill-creator'); + expect(skillCreator?.category).toBe('development'); + }); + + it('should accept an empty skills array', () => { + writeJson(testTempDir, 'skill.json', { skills: [] }); + const result = readSkillJson(testTempDir); + expect(result).not.toBeNull(); + expect(result?.skills).toHaveLength(0); + }); + }); + + // --------------------------------------------------------------------------- + // resolveSkillEntryPath + // --------------------------------------------------------------------------- + describe('resolveSkillEntryPath', () => { + it('should resolve "./" relative paths against repoDir', () => { + const resolved = resolveSkillEntryPath('/some/repo', './pdf'); + expect(resolved).toBe('/some/repo/pdf'); + }); + + it('should resolve "../" relative paths against repoDir', () => { + const resolved = resolveSkillEntryPath('/some/repo/sub', '../pdf'); + expect(resolved).toBe('/some/repo/pdf'); + }); + + it('should join bare names directly under repoDir', () => { + const resolved = resolveSkillEntryPath('/some/repo', 'pdf'); + expect(resolved).toBe('/some/repo/pdf'); + }); + + it('should handle nested relative paths', () => { + const resolved = resolveSkillEntryPath('/some/repo', './category/pdf'); + expect(resolved).toBe('/some/repo/category/pdf'); + }); + }); + + // --------------------------------------------------------------------------- + // getResolvedSkillEntries + // --------------------------------------------------------------------------- + describe('getResolvedSkillEntries', () => { + it('should return entries for existing skill directories', () => { + const skillDir = join(testTempDir, 'pdf'); + mkdirSync(skillDir, { recursive: true }); + + const skillJson = { + skills: [ + { name: 'pdf', path: './pdf', description: 'PDF tools', category: 'documents' }, + ], + }; + + const entries = getResolvedSkillEntries(testTempDir, skillJson); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('pdf'); + expect(entries[0].resolvedPath).toBe(skillDir); + expect(entries[0].category).toBe('documents'); + }); + + it('should exclude entries whose directories do not exist', () => { + const skillJson = { + skills: [ + { name: 'pdf', path: './pdf', description: 'PDF tools' }, + { name: 'missing', path: './missing', description: 'Does not exist' }, + ], + }; + + // Only create the 'pdf' directory + mkdirSync(join(testTempDir, 'pdf'), { recursive: true }); + + const entries = getResolvedSkillEntries(testTempDir, skillJson); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('pdf'); + }); + + it('should return empty array when no entries exist on disk', () => { + const skillJson = { + skills: [{ name: 'ghost', path: './ghost', description: 'Missing' }], + }; + + const entries = getResolvedSkillEntries(testTempDir, skillJson); + expect(entries).toHaveLength(0); + }); + + it('should preserve tags and other metadata on entries', () => { + mkdirSync(join(testTempDir, 'pdf'), { recursive: true }); + + const skillJson = { + skills: [ + { + name: 'pdf', + path: './pdf', + description: 'PDF tools', + tags: ['pdf', 'ocr'], + requires: { tools: ['python3'] }, + }, + ], + }; + + const entries = getResolvedSkillEntries(testTempDir, skillJson); + expect(entries[0].tags).toEqual(['pdf', 'ocr']); + expect(entries[0].requires?.tools).toEqual(['python3']); + }); + }); +});