diff --git a/CLAUDE.md b/CLAUDE.md index 704c1b38..5483cdc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,9 @@ skilld eject vue # Eject skill (portable, no symlinks) skilld eject vue --name vue # Eject with custom skill dir name skilld eject vue --out ./dir/ # Eject to custom path skilld eject vue --from 2025-07-01 # Only releases/issues since date +skilld author # Generate skill for npm publishing (monorepo-aware) +skilld author -m haiku # Author with specific LLM model +skilld author -o ./custom/ # Author to custom output directory ``` ## Architecture diff --git a/README.md b/README.md index 0e84e693..9c41bf80 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,52 @@ Share via `skilld add owner/repo` - consumers get fully functional skills with n | `--from` | | | Collect releases/issues/discussions from this date (YYYY-MM-DD, eject only) | | `--debug` | | `false` | Save raw LLM output to logs/ for each section | +## For Maintainers + +Ship skills with your npm package so consumers get them automatically. No LLM needed on their end. + +### Generate a skill + +From your package root (or monorepo root): + +```bash +npx skilld author +``` + +In a monorepo, skilld auto-detects workspaces and prompts which packages to generate for. Docs are resolved from: package `docs/`, monorepo `docs/content/`, `llms.txt`, or `README.md`. + +This creates a `skills//` directory with a `SKILL.md` and ejected reference files. It also adds `"skills"` to your `package.json` `files` array. + +### How consumers get it + +Once published, consumers run: + +```bash +npx skilld prepare +``` + +Or add it to their `package.json` so it runs on every install: + +```json +{ + "scripts": { + "prepare": "skilld prepare" + } +} +``` + +`skilld prepare` auto-detects shipped skills in `node_modules` and symlinks them into the agent's skill directory. Compatible with [skills-npm](https://github.com/antfu/skills-npm). + +### Options + +| Flag | Alias | Default | Description | +|:----------|:-----:|:-------:|:------------| +| `--model` | `-m` | | LLM model for enhancement | +| `--out` | `-o` | | Output directory (single package only) | +| `--force` | `-f` | `false` | Clear cache and regenerate | +| `--yes` | `-y` | `false` | Skip prompts, use defaults | +| `--debug` | | `false` | Save raw LLM output to logs/ | + ## The Landscape Several approaches exist for steering agent knowledge. Each fills a different niche: diff --git a/src/cli.ts b/src/cli.ts index b4b2e65e..7de26641 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -150,7 +150,7 @@ async function brandLoader(work: () => Promise, minMs = 1500): Promise // ── Subcommands (lazy-loaded) ── -const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare'] +const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish'] // ── Main command ── @@ -179,6 +179,8 @@ const main = defineCommand({ validate: () => import('./commands/validate.ts').then(m => m.validateCommandDef), assemble: () => import('./commands/assemble.ts').then(m => m.assembleCommandDef), setup: () => import('./commands/setup.ts').then(m => m.setupCommandDef), + author: () => import('./commands/author.ts').then(m => m.authorCommandDef), + publish: () => import('./commands/author.ts').then(m => m.authorCommandDef), }, async run({ args }) { // Guard: citty always calls parent run() after subcommand dispatch. diff --git a/src/commands/author.ts b/src/commands/author.ts new file mode 100644 index 00000000..c832151a --- /dev/null +++ b/src/commands/author.ts @@ -0,0 +1,670 @@ +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 * as p from '@clack/prompts' +import { defineCommand } from 'citty' +import { join, relative, resolve } from 'pathe' +import { + computeSkillDirName, + generateSkillMd, + getModelLabel, +} from '../agent/index.ts' +import { + ensureCacheDir, + getCacheDir, + writeToCache, +} from '../cache/index.ts' +import { guard } from '../cli-helpers.ts' +import { defaultFeatures, readConfig } from '../core/config.ts' +import { timedSpinner } from '../core/formatting.ts' +import { sanitizeMarkdown } from '../core/sanitize.ts' +import { + fetchGitHubDiscussions, + fetchGitHubIssues, + formatDiscussionAsMarkdown, + formatIssueAsMarkdown, + generateDiscussionIndex, + generateIssueIndex, + isGhAvailable, + parseGitHubUrl, + readLocalPackageInfo, +} from '../sources/index.ts' +import { + detectChangelog, + ejectReferences, + enhanceSkillWithLLM, + forceClearCache, + linkAllReferences, + selectLlmConfig, + writePromptFiles, +} from './sync-shared.ts' + +const QUOTE_PREFIX_RE = /^['"]/ +const QUOTE_SUFFIX_RE = /['"]$/ + +// ── Monorepo detection ── + +export interface MonorepoPackage { + name: string + version: string + description?: string + repoUrl?: string + dir: string +} + +export function detectMonorepoPackages(cwd: string): MonorepoPackage[] | null { + const pkgPath = join(cwd, 'package.json') + if (!existsSync(pkgPath)) + return null + + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + + // Must be private (monorepo root) with workspaces or pnpm-workspace.yaml + if (!pkg.private) + return null + + let patterns: string[] = [] + + if (Array.isArray(pkg.workspaces)) { + patterns = pkg.workspaces + } + else if (pkg.workspaces?.packages) { + patterns = pkg.workspaces.packages + } + + // Check pnpm-workspace.yaml + if (patterns.length === 0) { + const pnpmWs = join(cwd, 'pnpm-workspace.yaml') + if (existsSync(pnpmWs)) { + const lines = readFileSync(pnpmWs, 'utf-8').split('\n') + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('-')) + continue + const value = trimmed.slice(1).trim().replace(QUOTE_PREFIX_RE, '').replace(QUOTE_SUFFIX_RE, '') + if (value) + patterns.push(value) + } + } + } + + if (patterns.length === 0) + return null + + const packages: MonorepoPackage[] = [] + + for (const pattern of patterns) { + // Expand simple glob: "packages/*" → scan packages/*/package.json + const base = pattern.replace(/\/?\*+$/, '') + const scanDir = resolve(cwd, base) + if (!existsSync(scanDir)) + continue + + for (const entry of readdirSync(scanDir, { withFileTypes: true })) { + if (!entry.isDirectory()) + continue + const pkgJsonPath = join(scanDir, entry.name, 'package.json') + if (!existsSync(pkgJsonPath)) + continue + + const childPkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) + if (childPkg.private) + continue + if (!childPkg.name) + continue + + const repoUrl = typeof childPkg.repository === 'string' + ? childPkg.repository + : childPkg.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '') + + packages.push({ + name: childPkg.name, + version: childPkg.version || '0.0.0', + description: childPkg.description, + repoUrl, + dir: join(scanDir, entry.name), + }) + } + } + + return packages.length > 0 ? packages : null +} + +// ── Docs resolution ── + +function walkMarkdownFiles(dir: string, base = ''): Array<{ path: string, content: string }> { + const results: Array<{ path: string, content: string }> = [] + if (!existsSync(dir)) + return results + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = base ? `${base}/${entry.name}` : entry.name + const full = join(dir, entry.name) + if (entry.isDirectory()) { + results.push(...walkMarkdownFiles(full, rel)) + } + else if (/\.mdx?$/.test(entry.name)) { + results.push({ path: rel, content: readFileSync(full, 'utf-8') }) + } + } + return results +} + +/** + * Resolve docs from local filesystem. Cascade: + * 1. Package-level docs/ directory + * 2. Monorepo-root docs/ directory (if monorepoRoot provided) + * 3. Monorepo-root docs/content/ (Nuxt Content convention) + * 4. llms.txt in package dir + * 5. README.md in package dir + */ +function resolveLocalDocs( + packageDir: string, + packageName: string, + version: string, + monorepoRoot?: string, +): { docsType: 'docs' | 'llms.txt' | 'readme', docSource: string } { + const cachedDocs: Array<{ path: string, content: string }> = [] + + const cacheChangelog = () => cacheLocalChangelog(packageDir, packageName, version, monorepoRoot) + + // 1. Package-level docs/ + const docsDir = join(packageDir, 'docs') + if (existsSync(docsDir)) { + const mdFiles = walkMarkdownFiles(docsDir) + if (mdFiles.length > 0) { + for (const f of mdFiles) + cachedDocs.push({ path: `docs/${f.path}`, content: sanitizeMarkdown(f.content) }) + writeToCache(packageName, version, cachedDocs) + cacheChangelog() + return { docsType: 'docs', docSource: `local docs/ (${mdFiles.length} files)` } + } + } + + // 2. Monorepo-root docs/ or docs/content/ + if (monorepoRoot) { + for (const candidate of ['docs/content', 'docs']) { + const rootDocsDir = join(monorepoRoot, candidate) + if (existsSync(rootDocsDir)) { + const mdFiles = walkMarkdownFiles(rootDocsDir) + if (mdFiles.length > 0) { + for (const f of mdFiles) + cachedDocs.push({ path: `docs/${f.path}`, content: sanitizeMarkdown(f.content) }) + writeToCache(packageName, version, cachedDocs) + cacheChangelog() + return { docsType: 'docs', docSource: `monorepo ${candidate}/ (${mdFiles.length} files)` } + } + } + } + } + + // 3. llms.txt (package dir, then monorepo root) + for (const dir of [packageDir, monorepoRoot].filter(Boolean) as string[]) { + const llmsPath = join(dir, 'llms.txt') + if (existsSync(llmsPath)) { + cachedDocs.push({ path: 'llms.txt', content: sanitizeMarkdown(readFileSync(llmsPath, 'utf-8')) }) + writeToCache(packageName, version, cachedDocs) + cacheChangelog() + const source = dir === packageDir ? 'local llms.txt' : 'monorepo llms.txt' + return { docsType: 'llms.txt', docSource: source } + } + } + + // 4. README.md (package dir, then monorepo root) + for (const dir of [packageDir, monorepoRoot].filter(Boolean) as string[]) { + const readmeFile = readdirSync(dir).find(f => /^readme\.md$/i.test(f)) + if (readmeFile) { + cachedDocs.push({ path: 'docs/README.md', content: sanitizeMarkdown(readFileSync(join(dir, readmeFile), 'utf-8')) }) + writeToCache(packageName, version, cachedDocs) + cacheChangelog() + const source = dir === packageDir ? 'local README.md' : 'monorepo README.md' + return { docsType: 'readme', docSource: source } + } + } + + cacheChangelog() + return { docsType: 'readme', docSource: 'none' } +} + +function cacheLocalChangelog(dir: string, packageName: string, version: string, monorepoRoot?: string): void { + const candidates = ['CHANGELOG.md', 'changelog.md'] + const changelogFile = candidates.find(f => existsSync(join(dir, f))) + || (monorepoRoot ? candidates.find(f => existsSync(join(monorepoRoot, f))) : undefined) + const changelogDir = changelogFile && existsSync(join(dir, changelogFile)) ? dir : monorepoRoot + if (changelogFile && changelogDir) { + writeToCache(packageName, version, [{ + path: `releases/${changelogFile}`, + content: sanitizeMarkdown(readFileSync(join(changelogDir, changelogFile), 'utf-8')), + }]) + } +} + +// ── Remote supplements ── + +async function fetchRemoteSupplements(opts: { + packageName: string + version: string + repoUrl?: string + features: FeaturesConfig + onProgress: (msg: string) => void +}): Promise<{ hasIssues: boolean, hasDiscussions: boolean }> { + const { packageName, version, repoUrl, features, onProgress } = opts + + if (!repoUrl || !isGhAvailable()) + return { hasIssues: false, hasDiscussions: false } + + const gh = parseGitHubUrl(repoUrl) + if (!gh) + return { hasIssues: false, hasDiscussions: false } + + const cacheDir = getCacheDir(packageName, version) + + let hasIssues = false + const issuesDir = join(cacheDir, 'issues') + if (features.issues && !existsSync(issuesDir)) { + onProgress('Fetching issues via GitHub API') + const issues = await fetchGitHubIssues(gh.owner, gh.repo, 30).catch(() => []) + if (issues.length > 0) { + onProgress(`Caching ${issues.length} issues`) + writeToCache(packageName, version, issues.map(issue => ({ + path: `issues/issue-${issue.number}.md`, + content: formatIssueAsMarkdown(issue), + }))) + writeToCache(packageName, version, [{ + path: 'issues/_INDEX.md', + content: generateIssueIndex(issues), + }]) + hasIssues = true + } + } + else { + hasIssues = features.issues && existsSync(issuesDir) + } + + let hasDiscussions = false + const discussionsDir = join(cacheDir, 'discussions') + if (features.discussions && !existsSync(discussionsDir)) { + onProgress('Fetching discussions via GitHub API') + const discussions = await fetchGitHubDiscussions(gh.owner, gh.repo, 20).catch(() => []) + if (discussions.length > 0) { + onProgress(`Caching ${discussions.length} discussions`) + writeToCache(packageName, version, discussions.map(d => ({ + path: `discussions/discussion-${d.number}.md`, + content: formatDiscussionAsMarkdown(d), + }))) + writeToCache(packageName, version, [{ + path: 'discussions/_INDEX.md', + content: generateDiscussionIndex(discussions), + }]) + hasDiscussions = true + } + } + else { + hasDiscussions = features.discussions && existsSync(discussionsDir) + } + + return { hasIssues, hasDiscussions } +} + +// ── package.json patching ── + +export function patchPackageJsonFiles(packageDir: string): void { + const pkgPath = join(packageDir, 'package.json') + if (!existsSync(pkgPath)) + return + + const raw = readFileSync(pkgPath, 'utf-8') + const pkg = JSON.parse(raw) + + if (!Array.isArray(pkg.files)) { + p.log.warn('No `files` array in package.json. Add `"skills"` to your files array manually.') + return + } + + if (pkg.files.some((f: string) => f === 'skills' || f === 'skills/' || f === 'skills/**')) + return + + // Targeted insertion: find the closing bracket of the files array and insert before it + // This preserves the original formatting (indentation, trailing newlines, etc.) + const filesMatch = raw.match(/"files"\s*:\s*\[([^\]]*)\]/) + if (!filesMatch) { + // Fallback: full rewrite + pkg.files.push('skills') + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) + p.log.success('Added `"skills"` to package.json files array') + return + } + + const inner = filesMatch[1] + const trimmed = inner.trimEnd() + // Detect indentation from existing entries + const entryMatch = inner.match(/\n(\s+)"/) + const indent = entryMatch ? entryMatch[1] : ' ' + const needsComma = trimmed.length > 0 && !trimmed.endsWith(',') + const insertion = `${needsComma ? ',' : ''}\n${indent}"skills"` + const patched = raw.replace(filesMatch[0], `"files": [${trimmed}${insertion}\n${indent.slice(2) || ' '}]`) + writeFileSync(pkgPath, patched) + p.log.success('Added `"skills"` to package.json files array') +} + +// ── Core author flow for a single package ── + +async function authorSinglePackage(opts: { + packageDir: string + packageName: string + version: string + description?: string + repoUrl?: string + monorepoRoot?: string + out?: string + llmConfig?: LlmConfig | null + force?: boolean + debug?: boolean +}): Promise { + const { packageDir, packageName, version } = opts + const spin = timedSpinner() + + const sanitizedName = computeSkillDirName(packageName) + const outDir = opts.out ? resolve(packageDir, opts.out) : join(packageDir, 'skills', sanitizedName) + + // Validate --out doesn't point at the package root or a parent + if (opts.out) { + const rel = relative(packageDir, outDir) + if (!rel || rel === '.' || rel.startsWith('..')) { + p.log.error('--out must point to a child directory, not the package root or a parent') + return null + } + } + + if (existsSync(outDir)) + rmSync(outDir, { recursive: true, force: true }) + mkdirSync(outDir, { recursive: true }) + + if (opts.force) { + forceClearCache(packageName, version) + } + + ensureCacheDir() + const features = readConfig().features ?? defaultFeatures + + // Resolve local docs + spin.start('Resolving local docs') + const { docsType, docSource } = resolveLocalDocs(packageDir, packageName, version, opts.monorepoRoot) + spin.stop(`Resolved docs: ${docSource}`) + + // Fetch remote supplements (issues/discussions) + const supSpin = timedSpinner() + supSpin.start('Checking remote supplements') + const { hasIssues, hasDiscussions } = await fetchRemoteSupplements({ + packageName, + version, + repoUrl: opts.repoUrl, + features, + onProgress: msg => supSpin.message(msg), + }) + const supParts: string[] = [] + if (hasIssues) + supParts.push('issues') + if (hasDiscussions) + supParts.push('discussions') + supSpin.stop(supParts.length > 0 ? `Fetched ${supParts.join(', ')}` : 'No remote supplements') + + // Create temporary .skilld/ symlinks (LLM needs these to read docs) + linkAllReferences(outDir, packageName, packageDir, version, docsType, undefined, features) + + // Detect changelog + releases + const cacheDir = getCacheDir(packageName, version) + const hasChangelog = detectChangelog(packageDir, cacheDir) + const hasReleases = existsSync(join(cacheDir, 'releases')) + + // Generate base SKILL.md + const baseSkillMd = generateSkillMd({ + name: packageName, + version, + description: opts.description, + relatedSkills: [], + hasIssues, + hasDiscussions, + hasReleases, + hasChangelog, + docsType, + hasShippedDocs: false, + pkgFiles: [], + dirName: sanitizedName, + repoUrl: opts.repoUrl, + features, + eject: true, + }) + writeFileSync(join(outDir, 'SKILL.md'), baseSkillMd) + p.log.success(`Created base skill: ${relative(packageDir, outDir)}`) + + // LLM enhancement (config resolved by caller) + const skilldDir = join(outDir, '.skilld') + try { + const llmConfig = opts.llmConfig + if (llmConfig?.promptOnly) { + writePromptFiles({ + packageName, + skillDir: outDir, + version, + hasIssues, + hasDiscussions, + hasReleases, + hasChangelog, + docsType, + hasShippedDocs: false, + pkgFiles: [], + sections: llmConfig.sections, + customPrompt: llmConfig.customPrompt, + features, + }) + } + else if (llmConfig) { + p.log.step(getModelLabel(llmConfig.model)) + await enhanceSkillWithLLM({ + packageName, + version, + skillDir: outDir, + dirName: sanitizedName, + model: llmConfig.model, + resolved: { repoUrl: opts.repoUrl }, + relatedSkills: [], + hasIssues, + hasDiscussions, + hasReleases, + hasChangelog, + docsType, + hasShippedDocs: false, + pkgFiles: [], + force: opts.force, + debug: opts.debug, + sections: llmConfig.sections, + customPrompt: llmConfig.customPrompt, + features, + eject: true, + }) + } + + ejectReferences(outDir, packageName, packageDir, version, docsType, features) + } + finally { + // Always clean up .skilld/ symlinks, even if LLM enhancement fails + if (existsSync(skilldDir)) + rmSync(skilldDir, { recursive: true, force: true }) + } + + // Only patch package.json when output is under skills/ + const relOut = relative(packageDir, outDir) + if (relOut === 'skills' || relOut.startsWith('skills/')) + patchPackageJsonFiles(packageDir) + else if (opts.out) + p.log.info('Output is outside skills/, skipping package.json patch. Add the path to "files" manually if publishing.') + + return outDir +} + +// ── Main command ── + +async function resolveLlmConfig(model?: OptimizeModel, yes?: boolean): Promise { + const globalConfig = readConfig() + if (globalConfig.skipLlm || (yes && !model)) + return undefined + return selectLlmConfig(model) +} + +async function authorCommand(opts: { + out?: string + model?: OptimizeModel + yes?: boolean + force?: boolean + debug?: boolean +}): Promise { + const cwd = process.cwd() + + // Check for monorepo + const monoPackages = detectMonorepoPackages(cwd) + + if (monoPackages && monoPackages.length > 0) { + p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m author \x1B[90m(monorepo: ${monoPackages.length} packages)\x1B[0m`) + + if (opts.out) { + p.log.error('--out is not supported in monorepo mode (each package gets its own skills/ directory)') + return + } + + const selected = guard(await p.multiselect({ + message: 'Which packages should ship skills?', + options: monoPackages.map(pkg => ({ + label: pkg.name, + value: pkg, + hint: pkg.description, + })), + })) + + if (selected.length === 0) + return + + // Resolve LLM config once for all packages + const llmConfig = await resolveLlmConfig(opts.model, opts.yes) + + // Resolve monorepo-level repoUrl for packages that lack their own + const rootPkgPath = join(cwd, 'package.json') + const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf-8')) + const rootRepoUrl = typeof rootPkg.repository === 'string' + ? rootPkg.repository + : rootPkg.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '') + + const results: Array<{ name: string, outDir: string }> = [] + + for (const pkg of selected) { + p.log.step(`\x1B[36m${pkg.name}\x1B[0m@${pkg.version}`) + const outDir = await authorSinglePackage({ + packageDir: pkg.dir, + packageName: pkg.name, + version: pkg.version, + description: pkg.description, + repoUrl: pkg.repoUrl || rootRepoUrl, + monorepoRoot: cwd, + llmConfig, + force: opts.force, + debug: opts.debug, + }) + if (outDir) + results.push({ name: pkg.name, outDir }) + } + + if (results.length > 0) { + p.log.message('') + for (const { name, outDir } of results) + p.log.success(`${name} → ${relative(cwd, outDir)}`) + + printConsumerGuidance(results.map(r => r.name)) + } + + p.outro('Done') + return + } + + // Single package mode + const pkgInfo = readLocalPackageInfo(cwd) + if (!pkgInfo) { + p.log.error('No package.json found in current directory') + return + } + + const { name: packageName, version, repoUrl } = pkgInfo + + p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m author \x1B[36m${packageName}\x1B[0m@${version}`) + + const llmConfig = await resolveLlmConfig(opts.model, opts.yes) + + const outDir = await authorSinglePackage({ + packageDir: cwd, + packageName, + version, + description: pkgInfo.description, + repoUrl, + out: opts.out, + llmConfig, + force: opts.force, + debug: opts.debug, + }) + + if (outDir) { + printConsumerGuidance([packageName]) + p.outro(`Authored skill to ${relative(cwd, outDir)}`) + } +} + +function printConsumerGuidance(packageNames: string[]): void { + const names = packageNames.join(', ') + p.log.info( + `\x1B[90mConsumers get ${packageNames.length > 1 ? 'these skills' : 'this skill'} automatically:\x1B[0m\n` + + ` \x1B[90m1. Install ${names} as a dependency\x1B[0m\n` + + ` \x1B[90m2. Run \x1B[36mskilld prepare\x1B[90m (or add to package.json: \x1B[36m"prepare": "skilld prepare"\x1B[90m)\x1B[0m`, + ) +} + +export const authorCommandDef = defineCommand({ + meta: { name: 'author', description: 'Generate portable skill for npm publishing' }, + args: { + out: { + type: 'string', + alias: 'o', + description: 'Output directory (default: ./skills//)', + }, + model: { + type: 'string', + alias: 'm', + description: 'Enhancement model for SKILL.md generation', + valueHint: 'id', + }, + yes: { + type: 'boolean', + alias: 'y', + description: 'Skip prompts, use defaults', + default: false, + }, + force: { + type: 'boolean', + alias: 'f', + description: 'Clear cache and regenerate', + default: false, + }, + debug: { + type: 'boolean', + description: 'Save raw enhancement output to logs/', + default: false, + }, + }, + async run({ args }) { + await authorCommand({ + out: args.out, + model: args.model as OptimizeModel | undefined, + yes: args.yes, + force: args.force, + debug: args.debug, + }) + }, +}) diff --git a/test/unit/author.test.ts b/test/unit/author.test.ts new file mode 100644 index 00000000..e5e94e19 --- /dev/null +++ b/test/unit/author.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: vi.fn(), + readdirSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + rmSync: vi.fn(), + } +}) + +vi.mock('@clack/prompts', () => ({ + log: { success: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +describe('author', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('detectMonorepoPackages', () => { + it('returns null when no package.json exists', async () => { + const { existsSync } = await import('node:fs') + vi.mocked(existsSync).mockReturnValue(false) + + const { detectMonorepoPackages } = await import('../../src/commands/author') + expect(detectMonorepoPackages('/project')).toBeNull() + }) + + it('returns null for non-private packages', async () => { + const { existsSync, readFileSync } = await import('node:fs') + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'my-pkg', version: '1.0.0' })) + + const { detectMonorepoPackages } = await import('../../src/commands/author') + expect(detectMonorepoPackages('/project')).toBeNull() + }) + + it('detects npm workspaces array', async () => { + const { existsSync, readFileSync, readdirSync } = await import('node:fs') + + vi.mocked(existsSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return true + if (s === '/project/packages') + return true + if (s === '/project/packages/foo/package.json') + return true + return false + }) + + vi.mocked(readFileSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return JSON.stringify({ private: true, workspaces: ['packages/*'] }) + if (s === '/project/packages/foo/package.json') + return JSON.stringify({ name: '@scope/foo', version: '2.0.0', description: 'Foo package' }) + return '' + }) + + vi.mocked(readdirSync).mockReturnValue([ + { name: 'foo', isDirectory: () => true, isFile: () => false } as any, + ]) + + const { detectMonorepoPackages } = await import('../../src/commands/author') + const result = detectMonorepoPackages('/project') + + expect(result).toHaveLength(1) + expect(result![0]).toMatchObject({ + name: '@scope/foo', + version: '2.0.0', + description: 'Foo package', + }) + }) + + it('detects pnpm-workspace.yaml', async () => { + const { existsSync, readFileSync, readdirSync } = await import('node:fs') + + vi.mocked(existsSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return true + if (s === '/project/pnpm-workspace.yaml') + return true + if (s === '/project/packages') + return true + if (s === '/project/packages/bar/package.json') + return true + return false + }) + + vi.mocked(readFileSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return JSON.stringify({ private: true }) + if (s === '/project/pnpm-workspace.yaml') + return 'packages:\n - packages/*\n' + if (s === '/project/packages/bar/package.json') + return JSON.stringify({ name: 'bar', version: '1.0.0' }) + return '' + }) + + vi.mocked(readdirSync).mockReturnValue([ + { name: 'bar', isDirectory: () => true, isFile: () => false } as any, + ]) + + const { detectMonorepoPackages } = await import('../../src/commands/author') + const result = detectMonorepoPackages('/project') + + expect(result).toHaveLength(1) + expect(result![0].name).toBe('bar') + }) + + it('skips private child packages', async () => { + const { existsSync, readFileSync, readdirSync } = await import('node:fs') + + vi.mocked(existsSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return true + if (s === '/project/packages') + return true + if (s === '/project/packages/internal/package.json') + return true + if (s === '/project/packages/public/package.json') + return true + return false + }) + + vi.mocked(readFileSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return JSON.stringify({ private: true, workspaces: ['packages/*'] }) + if (s === '/project/packages/internal/package.json') + return JSON.stringify({ name: 'internal', private: true }) + if (s === '/project/packages/public/package.json') + return JSON.stringify({ name: 'public-pkg', version: '1.0.0' }) + return '' + }) + + vi.mocked(readdirSync).mockReturnValue([ + { name: 'internal', isDirectory: () => true, isFile: () => false } as any, + { name: 'public', isDirectory: () => true, isFile: () => false } as any, + ]) + + const { detectMonorepoPackages } = await import('../../src/commands/author') + const result = detectMonorepoPackages('/project') + + expect(result).toHaveLength(1) + expect(result![0].name).toBe('public-pkg') + }) + + it('handles pnpm-workspace.yaml with quoted entries', async () => { + const { existsSync, readFileSync, readdirSync } = await import('node:fs') + + vi.mocked(existsSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return true + if (s === '/project/pnpm-workspace.yaml') + return true + if (s === '/project/libs') + return true + if (s === '/project/libs/a/package.json') + return true + return false + }) + + vi.mocked(readFileSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return JSON.stringify({ private: true }) + if (s === '/project/pnpm-workspace.yaml') + return 'packages:\n - \'libs/*\'\n' + if (s === '/project/libs/a/package.json') + return JSON.stringify({ name: 'lib-a', version: '0.1.0' }) + return '' + }) + + vi.mocked(readdirSync).mockReturnValue([ + { name: 'a', isDirectory: () => true, isFile: () => false } as any, + ]) + + const { detectMonorepoPackages } = await import('../../src/commands/author') + const result = detectMonorepoPackages('/project') + + expect(result).toHaveLength(1) + expect(result![0].name).toBe('lib-a') + }) + + it('resolves repository URL from object form', async () => { + const { existsSync, readFileSync, readdirSync } = await import('node:fs') + + vi.mocked(existsSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return true + if (s === '/project/packages') + return true + if (s === '/project/packages/x/package.json') + return true + return false + }) + + vi.mocked(readFileSync).mockImplementation((p: any) => { + const s = String(p) + if (s === '/project/package.json') + return JSON.stringify({ private: true, workspaces: ['packages/*'] }) + if (s === '/project/packages/x/package.json') { + return JSON.stringify({ + name: 'x-pkg', + version: '1.0.0', + repository: { type: 'git', url: 'git+https://github.com/org/x.git' }, + }) + } + return '' + }) + + vi.mocked(readdirSync).mockReturnValue([ + { name: 'x', isDirectory: () => true, isFile: () => false } as any, + ]) + + const { detectMonorepoPackages } = await import('../../src/commands/author') + const result = detectMonorepoPackages('/project') + + expect(result![0].repoUrl).toBe('https://github.com/org/x') + }) + }) + + describe('patchPackageJsonFiles', () => { + it('warns when no files array exists', async () => { + const { existsSync, readFileSync } = await import('node:fs') + const { log } = await import('@clack/prompts') + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'test' })) + + const { patchPackageJsonFiles } = await import('../../src/commands/author') + patchPackageJsonFiles('/pkg') + + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No `files` array')) + }) + + it('adds skills to files array preserving formatting', async () => { + const { existsSync, readFileSync, writeFileSync } = await import('node:fs') + + vi.mocked(existsSync).mockReturnValue(true) + const original = `{ + "name": "test", + "files": [ + "dist" + ] +}` + vi.mocked(readFileSync).mockReturnValue(original) + + const { patchPackageJsonFiles } = await import('../../src/commands/author') + patchPackageJsonFiles('/pkg') + + const written = vi.mocked(writeFileSync).mock.calls[0][1] as string + expect(written).toContain('"skills"') + expect(written).toContain('"dist"') + // Should not have been reformatted by JSON.stringify (no double-space after "name") + expect(JSON.parse(written).files).toContain('skills') + }) + + it('skips if skills already in files array', async () => { + const { existsSync, readFileSync, writeFileSync } = await import('node:fs') + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ files: ['dist', 'skills'] })) + + const { patchPackageJsonFiles } = await import('../../src/commands/author') + patchPackageJsonFiles('/pkg') + + expect(writeFileSync).not.toHaveBeenCalled() + }) + + it('skips if skills/ variant already in files array', async () => { + const { existsSync, readFileSync, writeFileSync } = await import('node:fs') + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ files: ['dist', 'skills/'] })) + + const { patchPackageJsonFiles } = await import('../../src/commands/author') + patchPackageJsonFiles('/pkg') + + expect(writeFileSync).not.toHaveBeenCalled() + }) + + it('does nothing when no package.json exists', async () => { + const { existsSync, writeFileSync } = await import('node:fs') + + vi.mocked(existsSync).mockReturnValue(false) + + const { patchPackageJsonFiles } = await import('../../src/commands/author') + patchPackageJsonFiles('/pkg') + + expect(writeFileSync).not.toHaveBeenCalled() + }) + }) +})