From 54ab54ef75f2021955a2655dc45ce3d24c9bf3e5 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:04:34 +0200 Subject: [PATCH] refactor: centralize generated skill writes --- src/agent/index.ts | 2 +- src/agent/prompts/index.ts | 2 +- src/agent/prompts/skill.ts | 12 +++++++++++ src/commands/author.ts | 7 +++---- src/commands/install.ts | 12 +++++------ src/commands/sync-git.ts | 8 ++++---- src/commands/sync-parallel.ts | 13 +++++------- src/commands/sync-registry.ts | 5 +++-- src/commands/sync-shared.ts | 5 ++--- src/commands/sync.ts | 14 +++++-------- test/unit/agent-skill.test.ts | 38 +++++++++++++++++++++++++++++++++-- 11 files changed, 77 insertions(+), 41 deletions(-) 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 fc87dcc1..f5bf23e7 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, @@ -170,7 +170,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 }) @@ -597,7 +597,7 @@ async function enhanceRegenerated( const dirName = skillDir.split('/').pop() const allPackages = parsePackages(packages).map(p => ({ name: p.name })) - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: pkgName, version, description, @@ -613,7 +613,6 @@ async function enhanceRegenerated( packages: allPackages.length > 1 ? allPackages : undefined, features, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) } else { llmLog.message('Enhancement skipped') @@ -689,7 +688,8 @@ function regenerateBaseSkillMd( // Build multi-package list from lockfile packages field const allPackages = parsePackages(packages).map(p => ({ name: p.name })) - const content = generateSkillMd({ + mkdirSync(skillDir, { recursive: true }) + writeGeneratedSkillMd(skillDir, { name: pkgName, version, description, @@ -705,8 +705,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 6ab2aff4..9a7d236c 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 { @@ -262,7 +262,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, @@ -283,7 +283,6 @@ export async function syncPackagesParallel(config: ParallelSyncConfig): Promise< repoUrl: data.resolved.repoUrl, features: data.features, }) - writeFileSync(join(skillDir, 'SKILL.md'), skillMd) cachedPkgs.push(pkg) } } @@ -574,7 +573,7 @@ async function syncBaseSkill( const updatedLock = readLock(baseDir)?.skills[skillDirName] const allPackages = parsePackages(updatedLock?.packages).map(p => ({ name: p.name })) - const skillMd = generateSkillMd({ + const skillMd = writeGeneratedSkillMd(skillDir, { name: packageName, version, releasedAt: resolved.releasedAt, @@ -594,7 +593,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 @@ -682,7 +680,7 @@ async function enhanceWithLLM( } if (wasOptimized) { - const skillMd = generateSkillMd({ + writeGeneratedSkillMd(skillDir, { name: packageName, version: data.version, releasedAt: data.resolved.releasedAt, @@ -701,7 +699,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 c1a4fc26..74477ac4 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, @@ -479,7 +479,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, @@ -493,7 +493,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) @@ -604,7 +603,7 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi const allPackages = parsePackages(updatedLock?.packages).map(p => ({ name: p.name })) const isEject = !!config.eject - const baseSkillMd = generateSkillMd({ + const baseSkillMd = writeGeneratedSkillMd(skillDir, { name: identityPackageName, version, releasedAt: resolved.releasedAt, @@ -625,7 +624,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)}`) @@ -649,7 +647,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, @@ -672,7 +670,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') } @@ -1230,7 +1227,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, @@ -1249,7 +1246,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 = resolved.repoUrl?.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?(?:[/#]|$)/)?.[1] 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({