diff --git a/src/agent/index.ts b/src/agent/index.ts index 2ccffc7e..be0d1fd6 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -24,7 +24,7 @@ export { detectInstalledAgents, detectProjectAgents, detectTargetAgent, getAgent export { computeSkillDirName, installSkillForAgents, linkSkillToAgents, sanitizeName, unlinkSkillFromAgents } from './install.ts' // Skill generation -export { extractMarkedSections, generateSkillMd, getSectionValidator, portabilizePrompt, wrapSection } from './prompts/index.ts' +export { extractMarkedSections, generateSkillMd, getSectionValidator, portabilizePrompt, wrapSection, writeGeneratedSkillMd, writeSkillMd } from './prompts/index.ts' export type { SkillOptions } from './prompts/index.ts' // Registry diff --git a/src/agent/prompts/index.ts b/src/agent/prompts/index.ts index 0a6f0a5c..cf094015 100644 --- a/src/agent/prompts/index.ts +++ b/src/agent/prompts/index.ts @@ -1,5 +1,5 @@ export type { CustomPrompt, SectionValidationWarning } from './optional/index.ts' export { buildAllSectionPrompts, buildSectionPrompt, extractMarkedSections, getSectionValidator, portabilizePrompt, SECTION_MERGE_ORDER, SECTION_OUTPUT_FILES, wrapSection } from './prompt.ts' export type { BuildSkillPromptOptions, SkillSection } from './prompt.ts' -export { generateSkillMd } from './skill.ts' +export { generateSkillMd, writeGeneratedSkillMd, writeSkillMd } from './skill.ts' export type { SkillOptions } from './skill.ts' diff --git a/src/agent/prompts/skill.ts b/src/agent/prompts/skill.ts index 1978961e..89701121 100644 --- a/src/agent/prompts/skill.ts +++ b/src/agent/prompts/skill.ts @@ -3,6 +3,8 @@ */ import type { FeaturesConfig } from '../../core/config.ts' +import { writeFileSync } from 'node:fs' +import { join } from 'pathe' import { repairMarkdown, sanitizeMarkdown } from '../../core/sanitize.ts' import { resolveSkilldCommand } from '../../core/shared.ts' import { yamlEscape } from '../../core/yaml.ts' @@ -42,6 +44,16 @@ export interface SkillOptions { eject?: boolean } +export function writeSkillMd(skillDir: string, content: string): void { + writeFileSync(join(skillDir, 'SKILL.md'), content) +} + +export function writeGeneratedSkillMd(skillDir: string, opts: SkillOptions): string { + const content = generateSkillMd(opts) + writeSkillMd(skillDir, content) + return content +} + export function generateSkillMd(opts: SkillOptions): string { const header = generatePackageHeader(opts) const search = !opts.eject && opts.features?.search !== false ? generateSearchBlock(opts.name) : '' diff --git a/src/commands/author.ts b/src/commands/author.ts index e395ff40..a1697809 100644 --- a/src/commands/author.ts +++ b/src/commands/author.ts @@ -1,14 +1,14 @@ import type { OptimizeModel } from '../agent/index.ts' import type { FeaturesConfig } from '../core/config.ts' import type { LlmConfig } from './sync-shared.ts' -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs' import * as p from '@clack/prompts' import { defineCommand } from 'citty' import { join, relative, resolve } from 'pathe' import { computeSkillDirName, - generateSkillMd, getModelLabel, + writeGeneratedSkillMd, } from '../agent/index.ts' import { ensureCacheDir, @@ -421,7 +421,7 @@ async function authorSinglePackage(opts: { const hasReleases = existsSync(join(cacheDir, 'releases')) // Generate base SKILL.md - const baseSkillMd = generateSkillMd({ + writeGeneratedSkillMd(outDir, { name: packageName, version, description: opts.description, @@ -438,7 +438,6 @@ async function authorSinglePackage(opts: { features, eject: true, }) - writeFileSync(join(outDir, 'SKILL.md'), baseSkillMd) p.log.success(`Created base skill: ${relative(packageDir, outDir)}`) // LLM enhancement (config resolved by caller) diff --git a/src/commands/install.ts b/src/commands/install.ts index c8daf0ef..92e59954 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -18,7 +18,7 @@ import * as p from '@clack/prompts' import { defineCommand } from 'citty' import { dirname, join } from 'pathe' import { agents, createToolProgress, getModelLabel, linkSkillToAgents, optimizeDocs } from '../agent/index.ts' -import { generateSkillMd } from '../agent/prompts/skill.ts' +import { writeGeneratedSkillMd, writeSkillMd } from '../agent/prompts/skill.ts' import { hasShippedDocs as checkShippedDocs, ensureCacheDir, @@ -172,7 +172,7 @@ export async function installCommand(opts: InstallOptions): Promise { if (match) { const skillDir = join(skillsDir, name) mkdirSync(skillDir, { recursive: true }) - writeFileSync(join(skillDir, 'SKILL.md'), sanitizeMarkdown(match.content)) + writeSkillMd(skillDir, sanitizeMarkdown(match.content)) for (const f of match.files) { const filePath = join(skillDir, f.path) mkdirSync(dirname(filePath), { recursive: true }) @@ -586,7 +586,7 @@ async function enhanceRegenerated( const dirName = skillDir.split('/').pop() const allPackages = parsePackageNames(packages) - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: pkgName, version, description, @@ -602,7 +602,6 @@ async function enhanceRegenerated( packages: allPackages.length > 1 ? allPackages : undefined, features, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) } else { llmLog.message('Enhancement skipped') @@ -674,7 +673,8 @@ function regenerateBaseSkillMd( // Build multi-package list from lockfile packages field const allPackages = parsePackageNames(packages) - const content = generateSkillMd({ + mkdirSync(skillDir, { recursive: true }) + writeGeneratedSkillMd(skillDir, { name: pkgName, version, description, @@ -690,8 +690,6 @@ function regenerateBaseSkillMd( features: readConfig().features ?? defaultFeatures, }) - mkdirSync(skillDir, { recursive: true }) - writeFileSync(skillMdPath, content) return true } diff --git a/src/commands/sync-git.ts b/src/commands/sync-git.ts index 9b4146c0..54ca969e 100644 --- a/src/commands/sync-git.ts +++ b/src/commands/sync-git.ts @@ -10,10 +10,11 @@ import * as p from '@clack/prompts' import { dirname, join, relative } from 'pathe' import { agents, - generateSkillMd, getModelLabel, linkSkillToAgents, sanitizeName, + writeGeneratedSkillMd, + writeSkillMd, } from '../agent/index.ts' import { CACHE_DIR, @@ -134,7 +135,7 @@ export async function syncGitSkills(opts: GitSyncOptions): Promise { mkdirSync(skillDir, { recursive: true }) // Sanitize and write SKILL.md - writeFileSync(join(skillDir, 'SKILL.md'), sanitizeMarkdown(skill.content)) + writeSkillMd(skillDir, sanitizeMarkdown(skill.content)) // Write supporting files directly in skill dir (not under .skilld/) // so SKILL.md relative paths like ./references/docs/guide.md resolve correctly @@ -275,7 +276,7 @@ async function syncGitHubRepo(opts: GitSyncOptions): Promise { }) // Write base SKILL.md - const baseSkillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: packageName, version, releasedAt: resolved.releasedAt, @@ -292,7 +293,6 @@ async function syncGitHubRepo(opts: GitSyncOptions): Promise { repoUrl, features, }) - writeFileSync(join(skillDir, 'SKILL.md'), baseSkillMd) p.log.success(`Created base skill: ${relative(cwd, skillDir)}`) diff --git a/src/commands/sync-parallel.ts b/src/commands/sync-parallel.ts index 7dfbe869..9e48b3b6 100644 --- a/src/commands/sync-parallel.ts +++ b/src/commands/sync-parallel.ts @@ -1,7 +1,7 @@ import type { AgentType, CustomPrompt, OptimizeModel, SkillSection } from '../agent/index.ts' import type { FeaturesConfig } from '../core/config.ts' import type { ResolvedPackage } from '../sources/index.ts' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync } from 'node:fs' import * as p from '@clack/prompts' import logUpdate from 'log-update' import pLimit from 'p-limit' @@ -10,13 +10,13 @@ import { agents, computeSkillDirName, - generateSkillMd, getModelLabel, linkSkillToAgents, optimizeDocs, SECTION_MERGE_ORDER, SECTION_OUTPUT_FILES, wrapSection, + writeGeneratedSkillMd, } from '../agent/index.ts' import { @@ -263,7 +263,7 @@ export async function syncPackagesParallel(config: ParallelSyncConfig): Promise< } const cachedBody = cachedParts.join('\n\n') - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: resolvedName, version: data.version, releasedAt: data.resolved.releasedAt, @@ -284,7 +284,6 @@ export async function syncPackagesParallel(config: ParallelSyncConfig): Promise< repoUrl: data.resolved.repoUrl, features: data.features, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) cachedPkgs.push(pkg) } } @@ -575,7 +574,7 @@ async function syncBaseSkill( const updatedLock = readLock(baseDir)?.skills[skillDirName] const allPackages = parsePackageNames(updatedLock?.packages) - const skillMd = generateSkillMd({ + const skillMd = writeGeneratedSkillMd(skillDir, { name: packageName, version, releasedAt: resolved.releasedAt, @@ -595,7 +594,6 @@ async function syncBaseSkill( repoUrl: resolved.repoUrl, features, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) const overheadLines = skillMd.split('\n').length // Link shared dir to per-agent dirs @@ -683,7 +681,7 @@ async function enhanceWithLLM( } if (wasOptimized) { - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: packageName, version: data.version, releasedAt: data.resolved.releasedAt, @@ -702,7 +700,6 @@ async function enhanceWithLLM( repoUrl: data.resolved.repoUrl, features: data.features, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) } update(packageName, 'done', 'Skill optimized', versionKey) diff --git a/src/commands/sync-registry.ts b/src/commands/sync-registry.ts index dba4b1be..01c2b26c 100644 --- a/src/commands/sync-registry.ts +++ b/src/commands/sync-registry.ts @@ -9,9 +9,10 @@ import type { AgentType } from '../agent/index.ts' import type { RegistrySkill } from '../registry/client.ts' -import { mkdirSync, writeFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' import { join } from 'pathe' import { linkSkillToAgents } from '../agent/install.ts' +import { writeSkillMd } from '../agent/prompts/skill.ts' import { writeLock } from '../core/lockfile.ts' import { SHARED_SKILLS_DIR } from '../core/shared.ts' import { fetchRegistrySkill } from '../registry/client.ts' @@ -38,7 +39,7 @@ export async function syncRegistrySkill(opts: SyncRegistryOptions): Promise { for (const w of warnings) p.log.warn(`\x1B[33m${w}\x1B[0m`) } - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: packageName, version, releasedAt: resolved.releasedAt, @@ -1452,7 +1452,6 @@ export async function enhanceSkillWithLLM(opts: EnhanceOptions): Promise { features, eject, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) } else { if (error && /\b429\b|rate.?limit|exhausted.*capacity|quota.*reset/i.test(error)) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 172d33d5..5ca0a262 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -11,7 +11,6 @@ import { buildAllSectionPrompts, computeSkillDirName, detectImportedPackages, - generateSkillMd, getAvailableModels, getModelLabel, linkSkillToAgents, @@ -20,6 +19,7 @@ import { SECTION_MERGE_ORDER, SECTION_OUTPUT_FILES, wrapSection, + writeGeneratedSkillMd, } from '../agent/index.ts' import { ensureCacheDir, @@ -480,7 +480,7 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi const shippedDocs = hasShippedDocs(existingStorageName, cwd, existingLock.version) const mergeFeatures = readConfig().features ?? defaultFeatures - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: existingLock.packageName!, version: existingLock.version, relatedSkills, @@ -494,7 +494,6 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi packages: allPackages, features: mergeFeatures, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) const mergeShared = !config.global && getSharedSkillsDir(cwd) if (mergeShared) @@ -605,7 +604,7 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi const allPackages = parsePackageNames(updatedLock?.packages) const isEject = !!config.eject - const baseSkillMd = generateSkillMd({ + const baseSkillMd = writeGeneratedSkillMd(skillDir, { name: identityPackageName, version, releasedAt: resolved.releasedAt, @@ -626,7 +625,6 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi features, eject: isEject, }) - writeFileSync(join(skillDir, 'SKILL.md'), baseSkillMd) const overheadLines = baseSkillMd.split('\n').length p.log.success(config.mode === 'update' ? `Updated skill: ${relative(cwd, skillDir)}` : `Created base skill: ${relative(cwd, skillDir)}`) @@ -650,7 +648,7 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi } const cachedBody = cachedParts.join('\n\n') - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: identityPackageName, version, releasedAt: resolved.releasedAt, @@ -673,7 +671,6 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi features, eject: isEject, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) p.log.success('Applied cached SKILL.md sections') } @@ -1231,7 +1228,7 @@ export async function exportPortablePrompts(packageSpec: string, opts: { // Generate SKILL.md (ejected — uses ./references/ paths) const relatedSkills = await findRelatedSkills(packageName, join(skillDir, '..')) - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: packageName, version, releasedAt: resolved.releasedAt, @@ -1250,7 +1247,6 @@ export async function exportPortablePrompts(packageSpec: string, opts: { features, eject: true, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) // Write lockfile so skilld list/update/assemble can discover this skill const repoSlug = parseGitHubRepoSlug(resolved.repoUrl) diff --git a/test/unit/agent-skill.test.ts b/test/unit/agent-skill.test.ts index 543c2e0c..cb583dc9 100644 --- a/test/unit/agent-skill.test.ts +++ b/test/unit/agent-skill.test.ts @@ -1,7 +1,41 @@ -import { describe, expect, it } from 'vitest' -import { computeSkillDirName, generateSkillMd } from '../../src/agent' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { computeSkillDirName, generateSkillMd, writeGeneratedSkillMd, writeSkillMd } from '../../src/agent' describe('agent/skill', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs) + rmSync(dir, { recursive: true, force: true }) + tempDirs.length = 0 + }) + + describe('writeSkillMd', () => { + it('writes SKILL.md inside the skill directory', () => { + const skillDir = mkdtempSync(join(tmpdir(), 'skilld-skill-')) + tempDirs.push(skillDir) + + writeSkillMd(skillDir, '# Skill') + + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Skill') + }) + }) + + describe('writeGeneratedSkillMd', () => { + it('writes generated SKILL.md and returns the content', () => { + const skillDir = mkdtempSync(join(tmpdir(), 'skilld-skill-')) + tempDirs.push(skillDir) + + const content = writeGeneratedSkillMd(skillDir, { name: 'vue', relatedSkills: [] }) + + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe(content) + expect(content).toContain('name: vue-skilld') + }) + }) + describe('generateSkillMd', () => { it('generates frontmatter with consistent description format', () => { const result = generateSkillMd({