From 43bc46fa0a1cdd83605f3ec34036fc43da0143aa Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:29:19 +0000 Subject: [PATCH 1/7] feat(agents): add skill-to-subagent converter command Add `skillkit agent from-skill` command to convert SkillKit skills into Claude Code native subagent format (.md files in .claude/agents/). Features: - Reference mode (default): generates subagent with `skills: [skill-name]` - Inline mode (--inline): embeds full skill content in system prompt - Options: --model, --permission, --global, --output, --dry-run New files: - packages/core/src/agents/skill-converter.ts - packages/core/src/agents/__tests__/skill-converter.test.ts (23 tests) Closes #22 --- packages/cli/src/commands/agent.ts | 23 ++++++------------- .../agents/__tests__/skill-converter.test.ts | 17 ++++++++++++++ packages/core/src/agents/skill-converter.ts | 11 +++++---- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 0724caf6..b93325eb 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -34,7 +34,6 @@ import { isAgentInstalled, type BundledAgent, } from '@skillkit/resources'; -// Agent discovery uses root directories, not skill directories export class AgentCommand extends Command { static override paths = [['agent']]; @@ -263,7 +262,6 @@ export class AgentCreateCommand extends Command { }); async execute(): Promise { - // Validate agent name format const namePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; if (!namePattern.test(this.name)) { console.log(chalk.red('Invalid agent name: must be lowercase alphanumeric with hyphens')); @@ -271,7 +269,6 @@ export class AgentCreateCommand extends Command { return 1; } - // Determine target directory let targetDir: string; if (this.global) { targetDir = join(homedir(), '.claude', 'agents'); @@ -279,23 +276,19 @@ export class AgentCreateCommand extends Command { targetDir = join(process.cwd(), '.claude', 'agents'); } - // Create directory if needed if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } - // Check if agent already exists const agentPath = join(targetDir, `${this.name}.md`); if (existsSync(agentPath)) { console.log(chalk.red(`Agent already exists: ${agentPath}`)); return 1; } - // Generate content const description = this.description || `${this.name} agent`; const content = generateAgentTemplate(this.name, description, this.model); - // Write file writeFileSync(agentPath, content); console.log(chalk.green(`Created agent: ${agentPath}`)); @@ -359,11 +352,9 @@ export class AgentTranslateCommand extends Command { const searchDirs = [process.cwd()]; const targetAgent = this.to as AgentType; - // Get agents to translate let agents: CustomAgent[]; if (this.source) { - // Translate from custom source path const sourcePath = this.source.startsWith('/') ? this.source : join(process.cwd(), this.source); @@ -400,7 +391,6 @@ export class AgentTranslateCommand extends Command { return 0; } - // Determine output directory const outputDir = this.output || getAgentTargetDirectory(process.cwd(), targetAgent); console.log(chalk.cyan(`Translating ${agents.length} agent(s) to ${targetAgent} format...\n`)); @@ -433,7 +423,6 @@ export class AgentTranslateCommand extends Command { } } } else { - // Create directory if needed if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } @@ -538,12 +527,10 @@ export class AgentValidateCommand extends Command { let hasErrors = false; if (this.agentPath) { - // Validate specific path const result = validateAgent(this.agentPath); printValidationResult(this.agentPath, result); hasErrors = !result.valid; } else if (this.all) { - // Validate all agents const searchDirs = [process.cwd()]; const agents = findAllAgents(searchDirs); @@ -568,8 +555,6 @@ export class AgentValidateCommand extends Command { } } -// Helper functions - function printAgent(agent: CustomAgent): void { const status = agent.enabled ? chalk.green('✓') : chalk.red('○'); const name = agent.enabled ? agent.name : chalk.dim(agent.name); @@ -949,7 +934,13 @@ export class AgentFromSkillCommand extends Command { } filename = `${sanitized}.md`; } else { - filename = `${skill.name}.md`; + const sanitized = sanitizeFilename(skill.name); + if (!sanitized) { + console.log(chalk.red(`Invalid skill name for filename: ${skill.name}`)); + console.log(chalk.dim('Skill name must contain only alphanumeric characters, hyphens, and underscores')); + return 1; + } + filename = `${sanitized}.md`; } const outputPath = join(targetDir, filename); diff --git a/packages/core/src/agents/__tests__/skill-converter.test.ts b/packages/core/src/agents/__tests__/skill-converter.test.ts index 6b87e68c..f13baf30 100644 --- a/packages/core/src/agents/__tests__/skill-converter.test.ts +++ b/packages/core/src/agents/__tests__/skill-converter.test.ts @@ -311,5 +311,22 @@ Content here. expect(result.allowedTools).toEqual(['Read', 'Write', 'Bash(npm:*)']); }); + + it('should handle CRLF line endings', () => { + const crlfContent = `---\r\nname: test-skill\r\ndescription: Test\r\n---\r\n\r\n# Content\r\n\r\nWith CRLF endings.\r\n`; + + const skill: Skill = { + name: 'test-skill', + description: 'Test', + path: '/path/to/test-skill', + location: 'project', + enabled: true, + }; + + const result = skillToSubagent(skill, crlfContent, { inline: true }); + + expect(result.content).toContain('# Content'); + expect(result.content).toContain('With CRLF endings.'); + }); }); }); diff --git a/packages/core/src/agents/skill-converter.ts b/packages/core/src/agents/skill-converter.ts index a724a63b..566fe936 100644 --- a/packages/core/src/agents/skill-converter.ts +++ b/packages/core/src/agents/skill-converter.ts @@ -164,7 +164,7 @@ function generateSubagentMarkdown( ): string { const lines: string[] = ['---']; - lines.push(`name: ${canonical.name}`); + lines.push(`name: ${escapeYamlString(canonical.name)}`); lines.push(`description: ${escapeYamlString(canonical.description)}`); if (canonical.model) { @@ -185,10 +185,10 @@ function generateSubagentMarkdown( lines.push(`version: "${canonical.version}"`); } if (canonical.author) { - lines.push(`author: ${canonical.author}`); + lines.push(`author: ${escapeYamlString(canonical.author)}`); } if (canonical.tags && canonical.tags.length > 0) { - lines.push(`tags: [${canonical.tags.join(', ')}]`); + lines.push(`tags: [${canonical.tags.map(t => escapeYamlString(t)).join(', ')}]`); } if (canonical.userInvocable !== undefined) { lines.push(`user-invocable: ${canonical.userInvocable}`); @@ -203,7 +203,7 @@ function appendYamlList(lines: string[], key: string, items?: string[]): void { if (!items || items.length === 0) return; lines.push(`${key}:`); for (const item of items) { - lines.push(` - ${item}`); + lines.push(` - ${escapeYamlString(item)}`); } } @@ -211,7 +211,8 @@ function appendYamlList(lines: string[], key: string, items?: string[]): void { * Extract body content from skill markdown (without frontmatter) */ function extractBodyContent(content: string): string { - const match = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/); + const normalized = content.replace(/\r\n/g, '\n'); + const match = normalized.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/); if (match) { return match[1]; } From 9175acd311df4640bc3ef57b41bf773f409f3710 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:29:54 +0000 Subject: [PATCH 2/7] feat(publish): add RFC 8615 well-known skills support Redesign publish workflow to align with industry standard: - Add WellKnownProvider for auto-discovery from any domain - skillkit add https://example.com discovers skills via /.well-known/skills/ - skillkit publish generates well-known hosting structure - skillkit publish submit opens GitHub issue (legacy workflow) Provider discovers skills from: - /.well-known/skills/index.json (manifest) - /.well-known/skills/{skill-name}/SKILL.md (skill files) Includes 16 tests for WellKnownProvider and structure generation. --- apps/skillkit/src/cli.ts | 2 + packages/cli/src/commands/index.ts | 2 +- packages/cli/src/commands/publish.ts | 325 ++++++++++++++---- .../src/providers/__tests__/wellknown.test.ts | 172 +++++++++ packages/core/src/providers/index.ts | 3 + packages/core/src/providers/wellknown.ts | 205 +++++++++++ packages/core/src/types.ts | 2 +- 7 files changed, 634 insertions(+), 77 deletions(-) create mode 100644 packages/core/src/providers/__tests__/wellknown.test.ts create mode 100644 packages/core/src/providers/wellknown.ts diff --git a/apps/skillkit/src/cli.ts b/apps/skillkit/src/cli.ts index d27d1ad7..a5896437 100644 --- a/apps/skillkit/src/cli.ts +++ b/apps/skillkit/src/cli.ts @@ -48,6 +48,7 @@ import { AICommand, AuditCommand, PublishCommand, + PublishSubmitCommand, AgentCommand, AgentListCommand, AgentShowCommand, @@ -150,6 +151,7 @@ cli.register(CommandCmd); cli.register(AICommand); cli.register(AuditCommand); cli.register(PublishCommand); +cli.register(PublishSubmitCommand); cli.register(AgentCommand); cli.register(AgentListCommand); cli.register(AgentShowCommand); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index d73a591b..ab55eb9a 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -43,7 +43,7 @@ export { PlanCommand } from './plan.js'; export { CommandCmd, CommandAvailableCommand, CommandInstallCommand } from './command.js'; export { AICommand } from './ai.js'; export { AuditCommand } from './audit.js'; -export { PublishCommand } from './publish.js'; +export { PublishCommand, PublishSubmitCommand } from './publish.js'; export { AgentCommand, AgentListCommand, diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index bcc314f9..946b125d 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,8 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; -import { execSync } from 'node:child_process'; import chalk from 'chalk'; import { Command, Option } from 'clipanion'; +import { generateWellKnownIndex, type WellKnownSkill } from '@skillkit/core'; interface SkillFrontmatter { name?: string; @@ -15,22 +15,254 @@ export class PublishCommand extends Command { static override paths = [['publish']]; static override usage = Command.Usage({ - description: 'Publish your skill to the SkillKit marketplace', + description: 'Generate well-known skills structure for hosting', + details: ` + This command generates the RFC 8615 well-known URI structure for hosting skills. + + The output includes: + - .well-known/skills/index.json - Skill manifest for auto-discovery + - .well-known/skills/{skill-name}/SKILL.md - Individual skill files + + Users can then install skills via: skillkit add https://your-domain.com + `, examples: [ - ['Publish skill from current directory', '$0 publish'], - ['Publish skill from specific path', '$0 publish ./my-skill'], - ['Publish with custom name', '$0 publish --name my-awesome-skill'], + ['Generate from current directory', '$0 publish'], + ['Generate from specific path', '$0 publish ./my-skills'], + ['Generate to custom output directory', '$0 publish --output ./public'], + ['Preview without writing', '$0 publish --dry-run'], + ], + }); + + skillPath = Option.String({ required: false, name: 'path' }); + + output = Option.String('--output,-o', { + description: 'Output directory for well-known structure (default: current directory)', + }); + + dryRun = Option.Boolean('--dry-run,-n', false, { + description: 'Show what would be generated without writing files', + }); + + async execute(): Promise { + const basePath = this.skillPath || process.cwd(); + const outputDir = this.output || basePath; + + console.log(chalk.cyan('Generating well-known skills structure...\n')); + + const discoveredSkills = this.discoverSkills(basePath); + + if (discoveredSkills.length === 0) { + console.error(chalk.red('No skills found')); + console.error(chalk.dim('Skills must contain a SKILL.md file with frontmatter')); + return 1; + } + + console.log(chalk.white(`Found ${discoveredSkills.length} skill(s):\n`)); + + const wellKnownSkills: WellKnownSkill[] = []; + + for (const skill of discoveredSkills) { + const files = this.getSkillFiles(skill.path); + console.log(chalk.dim(` ${chalk.green('●')} ${skill.name}`)); + console.log(chalk.dim(` Description: ${skill.description || 'No description'}`)); + console.log(chalk.dim(` Files: ${files.join(', ')}`)); + + wellKnownSkills.push({ + name: skill.name, + description: skill.description, + files, + }); + } + + console.log(''); + + if (this.dryRun) { + console.log(chalk.yellow('Dry run - not writing files\n')); + console.log(chalk.white('Would generate:')); + console.log(chalk.dim(` ${outputDir}/.well-known/skills/index.json`)); + for (const skill of wellKnownSkills) { + for (const file of skill.files) { + console.log(chalk.dim(` ${outputDir}/.well-known/skills/${skill.name}/${file}`)); + } + } + console.log(''); + console.log(chalk.white('index.json preview:')); + console.log(JSON.stringify(generateWellKnownIndex(wellKnownSkills), null, 2)); + return 0; + } + + const wellKnownDir = join(outputDir, '.well-known', 'skills'); + mkdirSync(wellKnownDir, { recursive: true }); + + for (const skill of discoveredSkills) { + const skillDir = join(wellKnownDir, skill.name); + mkdirSync(skillDir, { recursive: true }); + + const files = this.getSkillFiles(skill.path); + for (const file of files) { + const sourcePath = join(skill.path, file); + const destPath = join(skillDir, file); + const content = readFileSync(sourcePath, 'utf-8'); + writeFileSync(destPath, content); + } + } + + const index = generateWellKnownIndex(wellKnownSkills); + writeFileSync(join(wellKnownDir, 'index.json'), JSON.stringify(index, null, 2)); + + console.log(chalk.green('Generated well-known structure:\n')); + console.log(chalk.dim(` ${wellKnownDir}/index.json`)); + for (const skill of wellKnownSkills) { + console.log(chalk.dim(` ${wellKnownDir}/${skill.name}/`)); + } + + console.log(''); + console.log(chalk.cyan('Next steps:')); + console.log(chalk.dim(' 1. Deploy the .well-known directory to your web server')); + console.log(chalk.dim(' 2. Users can install via: skillkit add https://your-domain.com')); + console.log(chalk.dim(' 3. Skills auto-discovered from /.well-known/skills/index.json')); + + return 0; + } + + private discoverSkills(basePath: string): Array<{ name: string; description?: string; path: string }> { + const skills: Array<{ name: string; description?: string; path: string }> = []; + + const skillMdPath = join(basePath, 'SKILL.md'); + if (existsSync(skillMdPath)) { + const content = readFileSync(skillMdPath, 'utf-8'); + const frontmatter = this.parseFrontmatter(content); + skills.push({ + name: frontmatter.name || basename(basePath), + description: frontmatter.description, + path: basePath, + }); + return skills; + } + + const searchDirs = [ + basePath, + join(basePath, 'skills'), + join(basePath, '.claude', 'skills'), + ]; + + for (const searchDir of searchDirs) { + if (!existsSync(searchDir)) continue; + + const entries = readdirSync(searchDir); + for (const entry of entries) { + const entryPath = join(searchDir, entry); + if (!statSync(entryPath).isDirectory()) continue; + + const entrySkillMd = join(entryPath, 'SKILL.md'); + if (existsSync(entrySkillMd)) { + const content = readFileSync(entrySkillMd, 'utf-8'); + const frontmatter = this.parseFrontmatter(content); + skills.push({ + name: frontmatter.name || entry, + description: frontmatter.description, + path: entryPath, + }); + } + } + } + + return skills; + } + + private getSkillFiles(skillPath: string): string[] { + const files: string[] = []; + + const entries = readdirSync(skillPath); + for (const entry of entries) { + const entryPath = join(skillPath, entry); + if (statSync(entryPath).isFile()) { + if (entry.startsWith('.') || entry === '.skillkit-metadata.json') continue; + files.push(entry); + } + } + + if (!files.includes('SKILL.md')) { + files.unshift('SKILL.md'); + } + + return files; + } + + private parseFrontmatter(content: string): SkillFrontmatter { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + + const frontmatter: SkillFrontmatter = {}; + const lines = match[1].split(/\r?\n/); + let inTagsList = false; + + for (const line of lines) { + if (inTagsList) { + const tagMatch = line.match(/^\s*-\s*(.+)$/); + if (tagMatch) { + frontmatter.tags ??= []; + frontmatter.tags.push(tagMatch[1].trim().replace(/^["']|["']$/g, '')); + continue; + } + if (line.trim() === '') continue; + inTagsList = false; + } + + const colonIdx = line.indexOf(':'); + if (colonIdx === -1) continue; + + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + + switch (key) { + case 'name': + frontmatter.name = value.replace(/^["']|["']$/g, ''); + break; + case 'description': + frontmatter.description = value.replace(/^["']|["']$/g, ''); + break; + case 'version': + frontmatter.version = value.replace(/^["']|["']$/g, ''); + break; + case 'tags': + if (value.startsWith('[')) { + frontmatter.tags = value + .slice(1, -1) + .split(',') + .map(t => t.trim().replace(/^["']|["']$/g, '')) + .filter(t => t.length > 0); + } else if (value === '') { + inTagsList = true; + frontmatter.tags = []; + } + break; + } + } + + return frontmatter; + } +} + +export class PublishSubmitCommand extends Command { + static override paths = [['publish', 'submit']]; + + static override usage = Command.Usage({ + description: 'Submit skill to SkillKit marketplace (requires review)', + examples: [ + ['Submit skill from current directory', '$0 publish submit'], + ['Submit with custom name', '$0 publish submit --name my-skill'], ], }); skillPath = Option.String({ required: false, name: 'path' }); name = Option.String('--name,-n', { - description: 'Custom skill name (default: parsed from SKILL.md)', + description: 'Custom skill name', }); dryRun = Option.Boolean('--dry-run', false, { - description: 'Show what would be published without actually publishing', + description: 'Show what would be submitted', }); async execute(): Promise { @@ -40,18 +272,15 @@ export class PublishCommand extends Command { if (!skillMdPath) { console.error(chalk.red('No SKILL.md found')); console.error(chalk.dim('Run this command from a directory containing SKILL.md')); - console.error(chalk.dim('Or specify the path: skillkit publish ./path/to/skill')); return 1; } - console.log(chalk.cyan('Publishing skill to SkillKit marketplace...\n')); + console.log(chalk.cyan('Submitting skill to SkillKit marketplace...\n')); - // Parse SKILL.md const content = readFileSync(skillMdPath, 'utf-8'); const frontmatter = this.parseFrontmatter(content); const skillName = this.name || frontmatter.name || basename(dirname(skillMdPath)); - // Get git repo info const repoInfo = this.getRepoInfo(dirname(skillMdPath)); if (!repoInfo) { console.error(chalk.red('Not a git repository or no remote configured')); @@ -59,19 +288,19 @@ export class PublishCommand extends Command { return 1; } - // Build skill entry const skillSlug = this.slugify(skillName); if (!skillSlug) { console.error(chalk.red('Skill name produces an empty slug.')); console.error(chalk.dim('Please pass --name with letters or numbers.')); return 1; } + const skillEntry = { id: `${repoInfo.owner}/${repoInfo.repo}/${skillSlug}`, name: this.formatName(skillName), - description: frontmatter.description || `Best practices and patterns for ${this.formatName(skillName)}`, + description: frontmatter.description || `Best practices for ${this.formatName(skillName)}`, source: `${repoInfo.owner}/${repoInfo.repo}`, - tags: frontmatter.tags || this.inferTags(skillName, frontmatter.description || ''), + tags: frontmatter.tags || ['general'], }; console.log(chalk.white('Skill details:')); @@ -83,13 +312,11 @@ export class PublishCommand extends Command { console.log(); if (this.dryRun) { - console.log(chalk.yellow('Dry run - not publishing')); - console.log(chalk.dim('JSON entry that would be added:')); + console.log(chalk.yellow('Dry run - not submitting')); console.log(JSON.stringify(skillEntry, null, 2)); return 0; } - // Create GitHub issue to add skill const issueBody = this.createIssueBody(skillEntry); const issueTitle = encodeURIComponent(`[Publish] ${skillEntry.name}`); const issueBodyEncoded = encodeURIComponent(issueBody); @@ -98,7 +325,7 @@ export class PublishCommand extends Command { console.log(chalk.green('Opening GitHub to submit your skill...\n')); try { - // Try to open the URL + const { execSync } = await import('node:child_process'); const openCmd = process.platform === 'darwin' ? `open "${issueUrl}"` @@ -108,39 +335,29 @@ export class PublishCommand extends Command { execSync(openCmd, { stdio: 'ignore' }); console.log(chalk.green('GitHub issue page opened!')); - console.log(chalk.dim('Review and submit the issue to publish your skill.')); + console.log(chalk.dim('Review and submit the issue.')); } catch { console.log(chalk.yellow('Could not open browser automatically.')); console.log(chalk.dim('Please open this URL manually:\n')); console.log(chalk.cyan(issueUrl)); } - console.log(); - console.log(chalk.cyan('Next steps:')); - console.log(chalk.dim(' 1. Review the skill details in the GitHub issue')); - console.log(chalk.dim(' 2. Submit the issue')); - console.log(chalk.dim(' 3. A maintainer will review and add your skill')); - return 0; } private findSkillMd(basePath: string): string | null { - // Check if path is directly to SKILL.md if (basePath.endsWith('SKILL.md') && existsSync(basePath)) { return basePath; } - // Check if SKILL.md exists in the directory const direct = join(basePath, 'SKILL.md'); if (existsSync(direct)) { return direct; } - // Check common skill locations const locations = [ join(basePath, 'skills', 'SKILL.md'), join(basePath, '.claude', 'skills', 'SKILL.md'), - join(basePath, '.cursor', 'skills', 'SKILL.md'), ]; for (const loc of locations) { @@ -158,21 +375,8 @@ export class PublishCommand extends Command { const frontmatter: SkillFrontmatter = {}; const lines = match[1].split(/\r?\n/); - let inTagsList = false; for (const line of lines) { - // Handle multiline tags list - if (inTagsList) { - const tagMatch = line.match(/^\s*-\s*(.+)$/); - if (tagMatch) { - frontmatter.tags ??= []; - frontmatter.tags.push(tagMatch[1].trim().replace(/^["']|["']$/g, '')); - continue; - } - if (line.trim() === '') continue; - inTagsList = false; - } - const colonIdx = line.indexOf(':'); if (colonIdx === -1) continue; @@ -190,16 +394,12 @@ export class PublishCommand extends Command { frontmatter.version = value.replace(/^["']|["']$/g, ''); break; case 'tags': - // Parse YAML array: [tag1, tag2] or multiline list if (value.startsWith('[')) { frontmatter.tags = value .slice(1, -1) .split(',') .map(t => t.trim().replace(/^["']|["']$/g, '')) .filter(t => t.length > 0); - } else if (value === '') { - inTagsList = true; - frontmatter.tags = []; } break; } @@ -218,19 +418,19 @@ export class PublishCommand extends Command { private getRepoInfo(dir: string): { owner: string; repo: string } | null { try { + const { execSync } = require('node:child_process'); const remote = execSync('git remote get-url origin', { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], }).trim(); - // Parse GitHub URL: git@github.com:owner/repo.git or https://github.com/owner/repo.git const match = remote.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/); if (match) { return { owner: match[1], repo: match[2] }; } } catch { - // Not a git repo or no remote + // Not a git repo } return null; @@ -243,31 +443,6 @@ export class PublishCommand extends Command { .join(' '); } - private inferTags(name: string, description: string): string[] { - const tags: string[] = []; - const text = `${name} ${description}`.toLowerCase(); - - const tagMap: Record = { - react: ['react', 'jsx', 'tsx'], - typescript: ['typescript', 'ts'], - nextjs: ['next', 'nextjs'], - testing: ['test', 'jest', 'vitest'], - mobile: ['mobile', 'react-native', 'expo'], - backend: ['backend', 'api', 'server'], - database: ['database', 'postgres', 'mysql', 'supabase'], - frontend: ['frontend', 'ui', 'design'], - devops: ['devops', 'ci', 'cd', 'docker'], - }; - - for (const [tag, keywords] of Object.entries(tagMap)) { - if (keywords.some(k => text.includes(k))) { - tags.push(tag); - } - } - - return tags.length > 0 ? tags : ['general']; - } - private createIssueBody( skill: { id: string; name: string; description: string; source: string; tags: string[] } ): string { @@ -292,6 +467,6 @@ ${JSON.stringify(skill, null, 2)} - [ ] Tags are appropriate --- -Submitted via \`skillkit publish\``; +Submitted via \`skillkit publish submit\``; } } diff --git a/packages/core/src/providers/__tests__/wellknown.test.ts b/packages/core/src/providers/__tests__/wellknown.test.ts new file mode 100644 index 00000000..3a89b1b9 --- /dev/null +++ b/packages/core/src/providers/__tests__/wellknown.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { WellKnownProvider, generateWellKnownIndex, generateWellKnownStructure } from '../wellknown.js'; +import { existsSync, rmSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +describe('WellKnownProvider', () => { + const provider = new WellKnownProvider(); + + describe('matches', () => { + it('should match https URLs not on github/gitlab/bitbucket', () => { + expect(provider.matches('https://example.com')).toBe(true); + expect(provider.matches('https://skills.vercel.app')).toBe(true); + expect(provider.matches('https://my-skills.com/api')).toBe(true); + }); + + it('should not match github.com URLs', () => { + expect(provider.matches('https://github.com/owner/repo')).toBe(false); + }); + + it('should not match gitlab.com URLs', () => { + expect(provider.matches('https://gitlab.com/owner/repo')).toBe(false); + }); + + it('should not match bitbucket.org URLs', () => { + expect(provider.matches('https://bitbucket.org/owner/repo')).toBe(false); + }); + + it('should not match local paths', () => { + expect(provider.matches('./my-skills')).toBe(false); + expect(provider.matches('/absolute/path')).toBe(false); + expect(provider.matches('~/home/skills')).toBe(false); + }); + + it('should not match shorthand GitHub format', () => { + expect(provider.matches('owner/repo')).toBe(false); + }); + }); + + describe('parseSource', () => { + it('should parse URL hostname and path', () => { + const result = provider.parseSource('https://example.com/api'); + expect(result).toEqual({ owner: 'example.com', repo: 'api' }); + }); + + it('should handle root URLs', () => { + const result = provider.parseSource('https://skills.example.com'); + expect(result).toEqual({ owner: 'skills.example.com', repo: 'skills' }); + }); + + it('should return null for invalid URLs', () => { + const result = provider.parseSource('not-a-url'); + expect(result).toBeNull(); + }); + }); + + describe('type and name', () => { + it('should have correct type', () => { + expect(provider.type).toBe('wellknown'); + }); + + it('should have correct name', () => { + expect(provider.name).toBe('Well-Known'); + }); + }); +}); + +describe('generateWellKnownIndex', () => { + it('should generate valid index structure', () => { + const skills = [ + { name: 'test-skill', description: 'A test skill', files: ['SKILL.md', 'README.md'] }, + { name: 'another-skill', description: 'Another skill', files: ['SKILL.md'] }, + ]; + + const index = generateWellKnownIndex(skills); + + expect(index.version).toBe('1.0'); + expect(index.skills).toHaveLength(2); + expect(index.skills[0]).toEqual({ + name: 'test-skill', + description: 'A test skill', + files: ['SKILL.md', 'README.md'], + }); + expect(index.skills[1]).toEqual({ + name: 'another-skill', + description: 'Another skill', + files: ['SKILL.md'], + }); + }); + + it('should handle empty skills array', () => { + const index = generateWellKnownIndex([]); + expect(index.version).toBe('1.0'); + expect(index.skills).toEqual([]); + }); +}); + +describe('generateWellKnownStructure', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `skillkit-test-${randomUUID()}`); + }); + + afterEach(() => { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should create well-known directory structure', () => { + const skills = [ + { + name: 'test-skill', + description: 'A test skill', + content: '---\nname: test-skill\ndescription: A test skill\n---\n\n# Test Skill\n\nContent here.', + }, + ]; + + const result = generateWellKnownStructure(tempDir, skills); + + expect(existsSync(join(tempDir, '.well-known', 'skills'))).toBe(true); + expect(existsSync(result.indexPath)).toBe(true); + expect(result.skillPaths).toContain(join(tempDir, '.well-known', 'skills', 'test-skill', 'SKILL.md')); + + const index = JSON.parse(readFileSync(result.indexPath, 'utf-8')); + expect(index.skills[0].name).toBe('test-skill'); + expect(index.skills[0].files).toContain('SKILL.md'); + }); + + it('should include additional files', () => { + const skills = [ + { + name: 'test-skill', + description: 'Test', + content: '# Test', + additionalFiles: { + 'README.md': '# README', + 'config.json': '{"key": "value"}', + }, + }, + ]; + + const result = generateWellKnownStructure(tempDir, skills); + + expect(result.skillPaths).toContain(join(tempDir, '.well-known', 'skills', 'test-skill', 'README.md')); + expect(result.skillPaths).toContain(join(tempDir, '.well-known', 'skills', 'test-skill', 'config.json')); + + const readme = readFileSync(join(tempDir, '.well-known', 'skills', 'test-skill', 'README.md'), 'utf-8'); + expect(readme).toBe('# README'); + + const index = JSON.parse(readFileSync(result.indexPath, 'utf-8')); + expect(index.skills[0].files).toContain('README.md'); + expect(index.skills[0].files).toContain('config.json'); + }); + + it('should handle multiple skills', () => { + const skills = [ + { name: 'skill-one', description: 'First', content: '# Skill One' }, + { name: 'skill-two', description: 'Second', content: '# Skill Two' }, + ]; + + const result = generateWellKnownStructure(tempDir, skills); + + expect(existsSync(join(tempDir, '.well-known', 'skills', 'skill-one', 'SKILL.md'))).toBe(true); + expect(existsSync(join(tempDir, '.well-known', 'skills', 'skill-two', 'SKILL.md'))).toBe(true); + + const index = JSON.parse(readFileSync(result.indexPath, 'utf-8')); + expect(index.skills).toHaveLength(2); + }); +}); diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index f577863f..4c453ce6 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -4,17 +4,20 @@ import { GitHubProvider } from './github.js'; import { GitLabProvider } from './gitlab.js'; import { BitbucketProvider } from './bitbucket.js'; import { LocalProvider } from './local.js'; +import { WellKnownProvider } from './wellknown.js'; export * from './base.js'; export * from './github.js'; export * from './gitlab.js'; export * from './bitbucket.js'; export * from './local.js'; +export * from './wellknown.js'; const providers: GitProviderAdapter[] = [ new LocalProvider(), new GitLabProvider(), new BitbucketProvider(), + new WellKnownProvider(), new GitHubProvider(), ]; diff --git a/packages/core/src/providers/wellknown.ts b/packages/core/src/providers/wellknown.ts new file mode 100644 index 00000000..5f73ec73 --- /dev/null +++ b/packages/core/src/providers/wellknown.ts @@ -0,0 +1,205 @@ +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import type { GitProviderAdapter, CloneOptions } from './base.js'; +import { isGitUrl, isLocalPath } from './base.js'; +import type { GitProvider, CloneResult } from '../types.js'; + +export interface WellKnownSkill { + name: string; + description?: string; + files: string[]; +} + +export interface WellKnownIndex { + version?: string; + skills: WellKnownSkill[]; +} + +export class WellKnownProvider implements GitProviderAdapter { + readonly type: GitProvider = 'wellknown'; + readonly name = 'Well-Known'; + readonly baseUrl = ''; + + parseSource(source: string): { owner: string; repo: string; subpath?: string } | null { + try { + const url = new URL(source); + return { owner: url.hostname, repo: url.pathname.replace(/^\//, '') || 'skills' }; + } catch { + return null; + } + } + + matches(source: string): boolean { + if (isLocalPath(source)) return false; + if (!isGitUrl(source)) return false; + if (source.includes('github.com')) return false; + if (source.includes('gitlab.com')) return false; + if (source.includes('bitbucket.org')) return false; + + try { + new URL(source); + return true; + } catch { + return false; + } + } + + getCloneUrl(_owner: string, _repo: string): string { + return ''; + } + + getSshUrl(_owner: string, _repo: string): string { + return ''; + } + + async clone(source: string, _targetDir: string, _options: CloneOptions = {}): Promise { + const tempDir = join(tmpdir(), `skillkit-wellknown-${randomUUID()}`); + + try { + mkdirSync(tempDir, { recursive: true }); + + const baseUrl = source.replace(/\/$/, ''); + const indexUrls = [ + `${baseUrl}/.well-known/skills/index.json`, + `${baseUrl}/.well-known/skills.json`, + ]; + + let index: WellKnownIndex | null = null; + let foundUrl = ''; + + for (const url of indexUrls) { + try { + const response = await fetch(url); + if (response.ok) { + index = await response.json() as WellKnownIndex; + foundUrl = url; + break; + } + } catch { + continue; + } + } + + if (!index || !index.skills || index.skills.length === 0) { + return { + success: false, + error: `No skills found at ${baseUrl}/.well-known/skills/index.json`, + }; + } + + const skills: string[] = []; + const discoveredSkills: Array<{ name: string; dirName: string; path: string }> = []; + const baseSkillsUrl = foundUrl.replace('/index.json', '').replace('/skills.json', '/.well-known/skills'); + + for (const skill of index.skills) { + const skillDir = join(tempDir, skill.name); + mkdirSync(skillDir, { recursive: true }); + + let hasSkillMd = false; + + for (const file of skill.files) { + const fileUrl = `${baseSkillsUrl}/${skill.name}/${file}`; + try { + const response = await fetch(fileUrl); + if (response.ok) { + const content = await response.text(); + writeFileSync(join(skillDir, basename(file)), content); + + if (file === 'SKILL.md' || file.endsWith('/SKILL.md')) { + hasSkillMd = true; + } + } + } catch { + continue; + } + } + + if (hasSkillMd) { + skills.push(skill.name); + discoveredSkills.push({ + name: skill.name, + dirName: skill.name, + path: skillDir, + }); + } + } + + if (skills.length === 0) { + rmSync(tempDir, { recursive: true, force: true }); + return { + success: false, + error: 'No valid skills found (skills must contain SKILL.md)', + }; + } + + return { + success: true, + path: tempDir, + tempRoot: tempDir, + skills, + discoveredSkills, + }; + } catch (error) { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to fetch skills: ${message}` }; + } + } +} + +export function generateWellKnownIndex(skills: Array<{ name: string; description?: string; files: string[] }>): WellKnownIndex { + return { + version: '1.0', + skills: skills.map(s => ({ + name: s.name, + description: s.description, + files: s.files, + })), + }; +} + +export function generateWellKnownStructure( + outputDir: string, + skills: Array<{ name: string; description?: string; content: string; additionalFiles?: Record }> +): { indexPath: string; skillPaths: string[] } { + const wellKnownDir = join(outputDir, '.well-known', 'skills'); + mkdirSync(wellKnownDir, { recursive: true }); + + const indexSkills: WellKnownSkill[] = []; + const skillPaths: string[] = []; + + for (const skill of skills) { + const skillDir = join(wellKnownDir, skill.name); + mkdirSync(skillDir, { recursive: true }); + + writeFileSync(join(skillDir, 'SKILL.md'), skill.content); + skillPaths.push(join(skillDir, 'SKILL.md')); + + const files = ['SKILL.md']; + + if (skill.additionalFiles) { + for (const [filename, content] of Object.entries(skill.additionalFiles)) { + writeFileSync(join(skillDir, filename), content); + files.push(filename); + skillPaths.push(join(skillDir, filename)); + } + } + + indexSkills.push({ + name: skill.name, + description: skill.description, + files, + }); + } + + const index = generateWellKnownIndex(indexSkills); + const indexPath = join(wellKnownDir, 'index.json'); + writeFileSync(indexPath, JSON.stringify(index, null, 2)); + + return { indexPath, skillPaths }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5556059f..5c11cf9a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -36,7 +36,7 @@ export const AgentType = z.enum([ ]); export type AgentType = z.infer; -export const GitProvider = z.enum(['github', 'gitlab', 'bitbucket', 'local']); +export const GitProvider = z.enum(['github', 'gitlab', 'bitbucket', 'local', 'wellknown']); export type GitProvider = z.infer; export const SkillFrontmatter = z.object({ From 0bc3422616882daaf4d7d8a27cdedb65a87d258f Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:36:48 +0000 Subject: [PATCH 3/7] fix(wellknown): correct baseSkillsUrl calculation for skills.json fallback When using the /.well-known/skills.json fallback URL, the previous logic produced a malformed URL with duplicated .well-known path segment: - Before: https://example.com/.well-known/.well-known/skills - After: https://example.com/.well-known/skills Extract calculateBaseSkillsUrl helper function for better testability and add 4 new unit tests covering both URL formats. --- .../src/providers/__tests__/wellknown.test.ts | 24 ++++++++++++++++++- packages/core/src/providers/wellknown.ts | 8 ++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/core/src/providers/__tests__/wellknown.test.ts b/packages/core/src/providers/__tests__/wellknown.test.ts index 3a89b1b9..4d74c0f0 100644 --- a/packages/core/src/providers/__tests__/wellknown.test.ts +++ b/packages/core/src/providers/__tests__/wellknown.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { WellKnownProvider, generateWellKnownIndex, generateWellKnownStructure } from '../wellknown.js'; +import { WellKnownProvider, generateWellKnownIndex, generateWellKnownStructure, calculateBaseSkillsUrl } from '../wellknown.js'; import { existsSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -66,6 +66,28 @@ describe('WellKnownProvider', () => { }); }); +describe('calculateBaseSkillsUrl', () => { + it('should handle index.json URL correctly', () => { + const result = calculateBaseSkillsUrl('https://example.com/.well-known/skills/index.json'); + expect(result).toBe('https://example.com/.well-known/skills'); + }); + + it('should handle skills.json URL correctly without duplicating .well-known', () => { + const result = calculateBaseSkillsUrl('https://example.com/.well-known/skills.json'); + expect(result).toBe('https://example.com/.well-known/skills'); + }); + + it('should handle nested paths with index.json', () => { + const result = calculateBaseSkillsUrl('https://cdn.example.com/v1/.well-known/skills/index.json'); + expect(result).toBe('https://cdn.example.com/v1/.well-known/skills'); + }); + + it('should handle nested paths with skills.json', () => { + const result = calculateBaseSkillsUrl('https://cdn.example.com/v1/.well-known/skills.json'); + expect(result).toBe('https://cdn.example.com/v1/.well-known/skills'); + }); +}); + describe('generateWellKnownIndex', () => { it('should generate valid index structure', () => { const skills = [ diff --git a/packages/core/src/providers/wellknown.ts b/packages/core/src/providers/wellknown.ts index 5f73ec73..109382af 100644 --- a/packages/core/src/providers/wellknown.ts +++ b/packages/core/src/providers/wellknown.ts @@ -91,7 +91,7 @@ export class WellKnownProvider implements GitProviderAdapter { const skills: string[] = []; const discoveredSkills: Array<{ name: string; dirName: string; path: string }> = []; - const baseSkillsUrl = foundUrl.replace('/index.json', '').replace('/skills.json', '/.well-known/skills'); + const baseSkillsUrl = calculateBaseSkillsUrl(foundUrl); for (const skill of index.skills) { const skillDir = join(tempDir, skill.name); @@ -152,6 +152,12 @@ export class WellKnownProvider implements GitProviderAdapter { } } +export function calculateBaseSkillsUrl(foundUrl: string): string { + return foundUrl.endsWith('/index.json') + ? foundUrl.replace('/index.json', '') + : foundUrl.replace('/skills.json', '/skills'); +} + export function generateWellKnownIndex(skills: Array<{ name: string; description?: string; files: string[] }>): WellKnownIndex { return { version: '1.0', From 8d70df66fbfbd1a41529ae74254ee9305b2467d2 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:41:17 +0000 Subject: [PATCH 4/7] docs: update publish command documentation for well-known skills - README.md: Add self-hosting section with well-known URI structure - commands.mdx: Add Publishing Commands section - marketplace.mdx: Expand publish section with self-hosting instructions - skills.mdx: Add self-hosting workflow documentation Documents the new `skillkit publish` workflow that generates RFC 8615 well-known URI structures for decentralized skill hosting. --- README.md | 22 ++++++++++++++- docs/fumadocs/content/docs/commands.mdx | 10 ++++++- docs/fumadocs/content/docs/marketplace.mdx | 33 +++++++++++++++++++++- docs/fumadocs/content/docs/skills.mdx | 20 +++++++++++-- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 29f605b2..ccc8abec 100644 --- a/README.md +++ b/README.md @@ -428,11 +428,31 @@ skillkit methodology load # Load a methodology ### Publishing & Sharing ```bash -skillkit publish # Publish skill to marketplace +skillkit publish # Generate well-known hosting structure +skillkit publish submit # Submit to SkillKit marketplace skillkit create # Create new skill skillkit init # Initialize in project ``` +#### Self-Hosting Skills (RFC 8615) + +Generate a well-known URI structure to host skills on your own domain: + +```bash +# Generate hosting structure +skillkit publish ./my-skills --output ./public + +# Users install from your domain +skillkit add https://your-domain.com +``` + +This creates: +``` +.well-known/skills/ + index.json # Skill manifest + my-skill/SKILL.md # Skill files +``` + ### Configuration ```bash diff --git a/docs/fumadocs/content/docs/commands.mdx b/docs/fumadocs/content/docs/commands.mdx index e5e21a5b..bb9be6ff 100644 --- a/docs/fumadocs/content/docs/commands.mdx +++ b/docs/fumadocs/content/docs/commands.mdx @@ -233,10 +233,18 @@ skillkit init # Initialize project skillkit create # Create new skill skillkit validate [path] # Validate skill format skillkit read # Read skill content -skillkit publish # Publish to marketplace skillkit settings --set key=value # Configure settings ``` +## Publishing Commands + +```bash +skillkit publish [path] # Generate well-known hosting structure +skillkit publish --output dir # Output to specific directory +skillkit publish --dry-run # Preview without writing +skillkit publish submit # Submit to SkillKit marketplace +``` + ## Interactive TUI ```bash diff --git a/docs/fumadocs/content/docs/marketplace.mdx b/docs/fumadocs/content/docs/marketplace.mdx index c4877546..bbf6e09d 100644 --- a/docs/fumadocs/content/docs/marketplace.mdx +++ b/docs/fumadocs/content/docs/marketplace.mdx @@ -48,8 +48,39 @@ skillkit install anthropics/skills --agent claude-code,cursor ## Publish Skills +### Self-Host on Your Domain (Recommended) + +Generate a well-known URI structure (RFC 8615) to host skills on your own domain: + ```bash +# Create and validate your skill skillkit create my-skill skillkit validate my-skill -skillkit publish + +# Generate hosting structure +skillkit publish ./my-skill --output ./public +``` + +This creates: +``` +.well-known/skills/ + index.json # Skill manifest for auto-discovery + my-skill/ + SKILL.md # Your skill content +``` + +Deploy the `.well-known` folder to your web server. Users can then install via: + +```bash +skillkit add https://your-domain.com +``` + +### Submit to SkillKit Marketplace + +To submit your skill for inclusion in the central marketplace: + +```bash +skillkit publish submit ``` + +This opens a GitHub issue for review by maintainers. diff --git a/docs/fumadocs/content/docs/skills.mdx b/docs/fumadocs/content/docs/skills.mdx index aa5361e7..6a49135a 100644 --- a/docs/fumadocs/content/docs/skills.mdx +++ b/docs/fumadocs/content/docs/skills.mdx @@ -94,6 +94,22 @@ Or manually create a `SKILL.md` file following the format above. # Test locally skillkit validate ./my-skill -# Publish to marketplace -skillkit publish +# Generate well-known hosting structure for self-hosting +skillkit publish ./my-skill --output ./public + +# Or submit to SkillKit marketplace +skillkit publish submit ``` + +### Self-Hosting + +The `skillkit publish` command generates an RFC 8615 well-known URI structure: + +``` +.well-known/skills/ + index.json # Manifest for auto-discovery + my-skill/ + SKILL.md # Skill content +``` + +Deploy to your domain and users can install via `skillkit add https://your-domain.com`. From 821fc1d4b044e078c7b0e2f0602186c8d52ebe1d Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:50:56 +0000 Subject: [PATCH 5/7] fix(security): address path traversal and CRLF vulnerabilities - agent.ts: Sanitize skill.name when building default filename - publish.ts: Add sanitizeSkillName() to validate skill names before using in file paths, verify resolved paths stay within target dir - skill-converter.ts: Normalize CRLF to LF before extracting body content to handle Windows-style line endings - wellknown.ts: Add sanitizeSkillName() to validate remote skill names, verify resolved paths stay within temp dir, encode URL components --- packages/cli/src/commands/publish.ts | 47 ++++++++++++++++++--- packages/core/src/agents/skill-converter.ts | 1 + packages/core/src/providers/wellknown.ts | 39 ++++++++++++++--- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 946b125d..1269b63d 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,9 +1,21 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs'; -import { join, basename, dirname } from 'node:path'; +import { join, basename, dirname, resolve } from 'node:path'; import chalk from 'chalk'; import { Command, Option } from 'clipanion'; import { generateWellKnownIndex, type WellKnownSkill } from '@skillkit/core'; +function sanitizeSkillName(name: string): string | null { + if (!name || typeof name !== 'string') return null; + const base = basename(name); + if (base !== name || name.includes('..') || name.includes('/') || name.includes('\\')) { + return null; + } + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + return null; + } + return name; +} + interface SkillFrontmatter { name?: string; description?: string; @@ -61,19 +73,33 @@ export class PublishCommand extends Command { const wellKnownSkills: WellKnownSkill[] = []; + const validSkills: Array<{ name: string; safeName: string; description?: string; path: string }> = []; + for (const skill of discoveredSkills) { + const safeName = sanitizeSkillName(skill.name); + if (!safeName) { + console.log(chalk.yellow(` ${chalk.yellow('⚠')} Skipping "${skill.name}" (invalid name - must be alphanumeric with hyphens/underscores)`)); + continue; + } + const files = this.getSkillFiles(skill.path); - console.log(chalk.dim(` ${chalk.green('●')} ${skill.name}`)); + console.log(chalk.dim(` ${chalk.green('●')} ${safeName}`)); console.log(chalk.dim(` Description: ${skill.description || 'No description'}`)); console.log(chalk.dim(` Files: ${files.join(', ')}`)); + validSkills.push({ name: skill.name, safeName, description: skill.description, path: skill.path }); wellKnownSkills.push({ - name: skill.name, + name: safeName, description: skill.description, files, }); } + if (validSkills.length === 0) { + console.error(chalk.red('\nNo valid skills to publish')); + return 1; + } + console.log(''); if (this.dryRun) { @@ -94,14 +120,23 @@ export class PublishCommand extends Command { const wellKnownDir = join(outputDir, '.well-known', 'skills'); mkdirSync(wellKnownDir, { recursive: true }); - for (const skill of discoveredSkills) { - const skillDir = join(wellKnownDir, skill.name); + for (const skill of validSkills) { + const skillDir = join(wellKnownDir, skill.safeName); + const resolvedSkillDir = resolve(skillDir); + const resolvedWellKnownDir = resolve(wellKnownDir); + + if (!resolvedSkillDir.startsWith(resolvedWellKnownDir)) { + console.log(chalk.yellow(` Skipping "${skill.name}" (path traversal detected)`)); + continue; + } + mkdirSync(skillDir, { recursive: true }); const files = this.getSkillFiles(skill.path); for (const file of files) { + const safeFile = basename(file); const sourcePath = join(skill.path, file); - const destPath = join(skillDir, file); + const destPath = join(skillDir, safeFile); const content = readFileSync(sourcePath, 'utf-8'); writeFileSync(destPath, content); } diff --git a/packages/core/src/agents/skill-converter.ts b/packages/core/src/agents/skill-converter.ts index 566fe936..ac57d41a 100644 --- a/packages/core/src/agents/skill-converter.ts +++ b/packages/core/src/agents/skill-converter.ts @@ -209,6 +209,7 @@ function appendYamlList(lines: string[], key: string, items?: string[]): void { /** * Extract body content from skill markdown (without frontmatter) + * Handles both Unix (LF) and Windows (CRLF) line endings */ function extractBodyContent(content: string): string { const normalized = content.replace(/\r\n/g, '\n'); diff --git a/packages/core/src/providers/wellknown.ts b/packages/core/src/providers/wellknown.ts index 109382af..9d7319fc 100644 --- a/packages/core/src/providers/wellknown.ts +++ b/packages/core/src/providers/wellknown.ts @@ -1,11 +1,23 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join, basename } from 'node:path'; +import { join, basename, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import type { GitProviderAdapter, CloneOptions } from './base.js'; import { isGitUrl, isLocalPath } from './base.js'; import type { GitProvider, CloneResult } from '../types.js'; +function sanitizeSkillName(name: string): string | null { + if (!name || typeof name !== 'string') return null; + const base = basename(name); + if (base !== name || name.includes('..') || name.includes('/') || name.includes('\\')) { + return null; + } + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + return null; + } + return name; +} + export interface WellKnownSkill { name: string; description?: string; @@ -94,18 +106,31 @@ export class WellKnownProvider implements GitProviderAdapter { const baseSkillsUrl = calculateBaseSkillsUrl(foundUrl); for (const skill of index.skills) { - const skillDir = join(tempDir, skill.name); + const safeName = sanitizeSkillName(skill.name); + if (!safeName) { + continue; + } + + const skillDir = join(tempDir, safeName); + const resolvedSkillDir = resolve(skillDir); + const resolvedTempDir = resolve(tempDir); + + if (!resolvedSkillDir.startsWith(resolvedTempDir + '/') && resolvedSkillDir !== resolvedTempDir) { + continue; + } + mkdirSync(skillDir, { recursive: true }); let hasSkillMd = false; for (const file of skill.files) { - const fileUrl = `${baseSkillsUrl}/${skill.name}/${file}`; + const fileUrl = `${baseSkillsUrl}/${encodeURIComponent(skill.name)}/${encodeURIComponent(file)}`; try { const response = await fetch(fileUrl); if (response.ok) { const content = await response.text(); - writeFileSync(join(skillDir, basename(file)), content); + const safeFileName = basename(file); + writeFileSync(join(skillDir, safeFileName), content); if (file === 'SKILL.md' || file.endsWith('/SKILL.md')) { hasSkillMd = true; @@ -117,10 +142,10 @@ export class WellKnownProvider implements GitProviderAdapter { } if (hasSkillMd) { - skills.push(skill.name); + skills.push(safeName); discoveredSkills.push({ - name: skill.name, - dirName: skill.name, + name: safeName, + dirName: safeName, path: skillDir, }); } From 642b2ae15d8716bea19668d9f233ed08e5012b42 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:34:07 +0000 Subject: [PATCH 6/7] fix(agents): improve YAML string escaping logic Only escape strings that actually need it for valid YAML parsing: - Strings containing newlines or colons - Strings starting with special YAML characters (-, *, &, !, {, [, >, |, @, `) - Strings starting with quotes This fixes unnecessary escaping of strings like "code-quality" where hyphens in the middle don't need escaping. --- packages/core/src/agents/skill-converter.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/skill-converter.ts b/packages/core/src/agents/skill-converter.ts index ac57d41a..c6cb9f9a 100644 --- a/packages/core/src/agents/skill-converter.ts +++ b/packages/core/src/agents/skill-converter.ts @@ -232,9 +232,25 @@ function formatAgentName(name: string): string { /** * Escape special characters in YAML strings + * Only escapes when necessary for valid YAML parsing */ function escapeYamlString(str: string): string { - if (/[:\{\}\[\],&*#?|\-<>=!%@`]/.test(str) || str.includes('\n')) { + if ( + str.includes('\n') || + str.includes(':') || + str.includes('#') || + str.startsWith('-') || + str.startsWith('*') || + str.startsWith('&') || + str.startsWith('!') || + str.startsWith('{') || + str.startsWith('[') || + str.startsWith('>') || + str.startsWith('|') || + str.startsWith('@') || + str.startsWith('`') || + /^['"]/.test(str) + ) { return `"${str.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; } return str; From 783bd1b193538a62c84b2939beebcd9a3826aa49 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:34:57 +0000 Subject: [PATCH 7/7] fix(wellknown): address security review feedback - Use path.sep instead of hardcoded '/' for cross-platform compatibility - Add sanitization and path containment check in generateWellKnownStructure - Remove unused 'vi' import in wellknown.test.ts --- .../src/providers/__tests__/wellknown.test.ts | 2 +- packages/core/src/providers/wellknown.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/core/src/providers/__tests__/wellknown.test.ts b/packages/core/src/providers/__tests__/wellknown.test.ts index 4d74c0f0..5b2b2435 100644 --- a/packages/core/src/providers/__tests__/wellknown.test.ts +++ b/packages/core/src/providers/__tests__/wellknown.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { WellKnownProvider, generateWellKnownIndex, generateWellKnownStructure, calculateBaseSkillsUrl } from '../wellknown.js'; import { existsSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/packages/core/src/providers/wellknown.ts b/packages/core/src/providers/wellknown.ts index 9d7319fc..34e3f53d 100644 --- a/packages/core/src/providers/wellknown.ts +++ b/packages/core/src/providers/wellknown.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join, basename, resolve } from 'node:path'; +import { join, basename, resolve, sep } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import type { GitProviderAdapter, CloneOptions } from './base.js'; @@ -115,7 +115,7 @@ export class WellKnownProvider implements GitProviderAdapter { const resolvedSkillDir = resolve(skillDir); const resolvedTempDir = resolve(tempDir); - if (!resolvedSkillDir.startsWith(resolvedTempDir + '/') && resolvedSkillDir !== resolvedTempDir) { + if (!resolvedSkillDir.startsWith(resolvedTempDir + sep) && resolvedSkillDir !== resolvedTempDir) { continue; } @@ -199,13 +199,25 @@ export function generateWellKnownStructure( skills: Array<{ name: string; description?: string; content: string; additionalFiles?: Record }> ): { indexPath: string; skillPaths: string[] } { const wellKnownDir = join(outputDir, '.well-known', 'skills'); + const resolvedWellKnownDir = resolve(wellKnownDir); mkdirSync(wellKnownDir, { recursive: true }); const indexSkills: WellKnownSkill[] = []; const skillPaths: string[] = []; for (const skill of skills) { - const skillDir = join(wellKnownDir, skill.name); + const safeName = sanitizeSkillName(skill.name); + if (!safeName) { + continue; + } + + const skillDir = join(wellKnownDir, safeName); + const resolvedSkillDir = resolve(skillDir); + + if (!resolvedSkillDir.startsWith(resolvedWellKnownDir + sep) && resolvedSkillDir !== resolvedWellKnownDir) { + continue; + } + mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), skill.content);