diff --git a/build.config.ts b/build.config.ts index 366a6f2b..dc3ab4c6 100644 --- a/build.config.ts +++ b/build.config.ts @@ -6,7 +6,9 @@ export default defineBuildConfig({ type: 'bundle', input: [ './src/index.ts', + './src/cli-entry.ts', './src/cli.ts', + './src/prepare.ts', './src/types.ts', './src/cache/index.ts', './src/retriv/index.ts', diff --git a/package.json b/package.json index 98e670a5..fbc72635 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "main": "./dist/index.mjs", "types": "./dist/index.d.mts", "bin": { - "skilld": "./dist/cli.mjs" + "skilld": "./dist/cli-entry.mjs" }, "files": [ "dist", @@ -53,7 +53,7 @@ "test:run": "vitest run", "release": "pnpm build && bumpp -x \"npx changelogen --output=CHANGELOG.md\"", "prepack": "pnpm run build", - "prepare": "skilld prepare" + "prepare": "test -z \"$CI\" && skilld prepare || true" }, "dependencies": { "@clack/prompts": "catalog:deps", diff --git a/src/cache/storage.ts b/src/cache/storage.ts index 2afd122b..748c6668 100644 --- a/src/cache/storage.ts +++ b/src/cache/storage.ts @@ -5,6 +5,7 @@ import type { CachedDoc, CachedPackage } from './types.ts' import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { basename, join, resolve } from 'pathe' +import { resolvePkgDir } from '../core/prepare.ts' import { sanitizeMarkdown } from '../core/sanitize.ts' import { getRepoCacheDir, REFERENCES_DIR, REPOS_DIR } from './config.ts' import { getCacheDir } from './version.ts' @@ -122,20 +123,9 @@ export function linkCachedDir(skillDir: string, name: string, version: string, s * Resolve the package directory: node_modules first, then cached dist fallback. * Returns the path if found, null otherwise. */ -export function resolvePkgDir(name: string, cwd: string, version?: string): string | null { - const nodeModulesPath = join(cwd, 'node_modules', name) - if (existsSync(nodeModulesPath)) - return nodeModulesPath - - // Fallback: check cached npm dist - if (version) { - const cachedPkgDir = join(getCacheDir(name, version), 'pkg') - if (existsSync(join(cachedPkgDir, 'package.json'))) - return cachedPkgDir - } - - return null -} +export { resolvePkgDir } from '../core/prepare.ts' +export { getShippedSkills, linkShippedSkill } from '../core/prepare.ts' +export type { ShippedSkill } from '../core/prepare.ts' /** * Create symlink from .skilld dir to package directory @@ -223,31 +213,6 @@ export function getPkgKeyFiles(name: string, cwd: string, version?: string): str return [...new Set(files)] } -/** - * Check if package ships its own docs folder - */ -export interface ShippedSkill { - skillName: string - skillDir: string -} - -/** - * Check if package ships a skills/ directory with SKILL.md or _SKILL.md subdirs - */ -export function getShippedSkills(name: string, cwd: string, version?: string): ShippedSkill[] { - const pkgPath = resolvePkgDir(name, cwd, version) - if (!pkgPath) - return [] - - const skillsPath = join(pkgPath, 'skills') - if (!existsSync(skillsPath)) - return [] - - return readdirSync(skillsPath, { withFileTypes: true }) - .filter(d => d.isDirectory() && (existsSync(join(skillsPath, d.name, 'SKILL.md')) || existsSync(join(skillsPath, d.name, '_SKILL.md')))) - .map(d => ({ skillName: d.name, skillDir: join(skillsPath, d.name) })) -} - /** * Write LLM-generated section outputs to global cache for cross-project reuse * @@ -273,20 +238,6 @@ export function readCachedSection(name: string, version: string, file: string): return readFileSync(path, 'utf-8') } -/** - * Create symlink from skills dir to shipped skill dir - */ -export function linkShippedSkill(baseDir: string, skillName: string, targetDir: string): void { - const linkPath = join(baseDir, skillName) - if (existsSync(linkPath)) { - const stat = lstatSync(linkPath) - if (stat.isSymbolicLink()) - unlinkSync(linkPath) - else rmSync(linkPath, { recursive: true, force: true }) - } - symlinkSync(targetDir, linkPath) -} - export function hasShippedDocs(name: string, cwd: string, version?: string): boolean { const pkgPath = resolvePkgDir(name, cwd, version) if (!pkgPath) diff --git a/src/cli-entry.ts b/src/cli-entry.ts new file mode 100644 index 00000000..11f2099b --- /dev/null +++ b/src/cli-entry.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +/** + * CLI entry point. Intercepts `skilld prepare` to run the fast path (~45ms) + * before the full CLI loads (~200ms of module imports). + */ + +// eslint-disable-next-line antfu/no-top-level-await +await import(process.argv[2] === 'prepare' && process.argv.length <= 3 + ? './prepare.ts' + : './cli.ts', +) diff --git a/src/cli-helpers.ts b/src/cli-helpers.ts index 206bd9c8..8045e863 100644 --- a/src/cli-helpers.ts +++ b/src/cli-helpers.ts @@ -497,17 +497,18 @@ export async function suggestPrepareHook(cwd: string = process.cwd()): Promise join(cwd, t.skillsDir)) - const allLocks = allSkillsDirs - .map(dir => readLock(dir)) - .filter((l): l is NonNullable => !!l && Object.keys(l.skills).length > 0) - - if (allLocks.length > 0) { - const lock = mergeLocks(allLocks) + const lock = readLock(skillsDir) + if (lock && Object.keys(lock.skills).length > 0) { + let allIntact = true for (const [name, info] of Object.entries(lock.skills)) { if (!info.version) continue - if (info.source === 'shipped') { - const skillDir = join(skillsDir, name) - if (!existsSync(skillDir)) { - const pkgName = info.packageName || name - const shipped = getShippedSkills(pkgName, cwd, info.version) - const match = shipped.find(s => s.skillName === name) - if (match) - linkShippedSkill(skillsDir, name, match.skillDir) - } + const skillDir = join(skillsDir, name) + if (existsSync(skillDir)) { + // Skill dir exists; for non-shipped, also check .skilld/pkg symlink + if (info.source !== 'shipped') + restorePkgSymlink(skillsDir, name, info, cwd) continue } - // Non-shipped: restore .skilld/pkg symlink if broken - restorePkgSymlink(skillsDir, name, info, cwd) + // Skill dir missing, needs restore + allIntact = false + + if (info.source === 'shipped') { + const pkgName = info.packageName || name + const shipped = getShippedSkills(pkgName, cwd, info.version) + const match = shipped.find(s => s.skillName === name) + if (match) + linkShippedSkill(skillsDir, name, match.skillDir) + } } + + // If all skills intact, skip expensive getProjectState entirely + if (allIntact) + return } - // ── 2. Auto-install shipped skills from deps ── + // ── Slow path: discover new shipped skills + report outdated ── const state = await getProjectState(cwd) let shippedCount = 0 @@ -105,35 +106,9 @@ export const prepareCommandDef = defineCommand({ p.log.success(`Installed ${shippedCount} shipped skill${shippedCount > 1 ? 's' : ''}`) } - // ── 3. Report outdated skills ── - - // Re-read state after shipped installs so they don't show as missing - const freshState = shippedCount > 0 ? await getProjectState(cwd) : state - - if (freshState.outdated.length > 0) { - const n = freshState.outdated.length + if (state.outdated.length > 0) { + const n = state.outdated.length p.log.info(`${n} package${n > 1 ? 's' : ''} ha${n > 1 ? 've' : 's'} new features and/or breaking changes. Run \`skilld update\` to sync.`) } }, }) - -/** Restore .skilld/pkg symlink to node_modules if broken */ -function restorePkgSymlink(skillsDir: string, name: string, info: SkillInfo, cwd: string): void { - const refsDir = join(skillsDir, name, '.skilld') - const pkgLink = join(refsDir, 'pkg') - - // Only fix if the skill dir exists but the pkg symlink is broken - if (!existsSync(join(skillsDir, name))) - return - - if (existsSync(pkgLink)) - return - - const pkgName = info.packageName || name - const pkgDir = resolvePkgDir(pkgName, cwd, info.version) - if (!pkgDir) - return - - mkdirSync(refsDir, { recursive: true }) - symlinkSync(pkgDir, pkgLink) -} diff --git a/src/core/prepare.ts b/src/core/prepare.ts new file mode 100644 index 00000000..8844ffb4 --- /dev/null +++ b/src/core/prepare.ts @@ -0,0 +1,79 @@ +/** + * Shared prepare utilities used by both the fast entry (src/prepare.ts) + * and the full CLI command (src/commands/prepare.ts). + * + * Keep this module lightweight: no imports from agent/, cache/storage.ts, + * or any module that pulls in sanitize/clack/citty. + */ + +import type { SkillInfo } from './lockfile.ts' +import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs' +import { join } from 'pathe' +import { getCacheDir } from '../cache/version.ts' + +/** Resolve package directory: node_modules first, then global cache */ +export function resolvePkgDir(name: string, cwd: string, version?: string): string | null { + const nodeModulesPath = join(cwd, 'node_modules', name) + if (existsSync(nodeModulesPath)) + return nodeModulesPath + + if (version) { + const cachedPkgDir = join(getCacheDir(name, version), 'pkg') + if (existsSync(join(cachedPkgDir, 'package.json'))) + return cachedPkgDir + } + + return null +} + +/** Restore .skilld/pkg symlink to node_modules if broken */ +export function restorePkgSymlink(skillsDir: string, name: string, info: SkillInfo, cwd: string): void { + const refsDir = join(skillsDir, name, '.skilld') + const pkgLink = join(refsDir, 'pkg') + + if (!existsSync(join(skillsDir, name))) + return + + if (existsSync(pkgLink)) + return + + const pkgName = info.packageName || name + const pkgDir = resolvePkgDir(pkgName, cwd, info.version) + if (!pkgDir) + return + + mkdirSync(refsDir, { recursive: true }) + symlinkSync(pkgDir, pkgLink) +} + +export interface ShippedSkill { + skillName: string + skillDir: string +} + +/** Check if package ships a skills/ directory with SKILL.md or _SKILL.md subdirs */ +export function getShippedSkills(name: string, cwd: string, version?: string): ShippedSkill[] { + const pkgPath = resolvePkgDir(name, cwd, version) + if (!pkgPath) + return [] + + const skillsPath = join(pkgPath, 'skills') + if (!existsSync(skillsPath)) + return [] + + return readdirSync(skillsPath, { withFileTypes: true }) + .filter(d => d.isDirectory() && (existsSync(join(skillsPath, d.name, 'SKILL.md')) || existsSync(join(skillsPath, d.name, '_SKILL.md')))) + .map(d => ({ skillName: d.name, skillDir: join(skillsPath, d.name) })) +} + +/** Create symlink from skills dir to shipped skill dir */ +export function linkShippedSkill(baseDir: string, skillName: string, targetDir: string): void { + const linkPath = join(baseDir, skillName) + if (existsSync(linkPath)) { + const stat = lstatSync(linkPath) + if (stat.isSymbolicLink()) + unlinkSync(linkPath) + else rmSync(linkPath, { recursive: true, force: true }) + } + symlinkSync(targetDir, linkPath) +} diff --git a/src/prepare.ts b/src/prepare.ts new file mode 100644 index 00000000..802e823b --- /dev/null +++ b/src/prepare.ts @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Ultra-fast prepare entry point for package.json "prepare" hook. + * + * Avoids loading the full CLI (citty, clack, agent registry, etc.) which adds ~200ms. + * Fast path: read lockfile, verify skill dirs exist, exit. Typically <20ms. + * Falls back to full CLI for shipped skill discovery and symlink restoration. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { readLock } from './core/lockfile.ts' +import { getShippedSkills, linkShippedSkill, restorePkgSymlink } from './core/prepare.ts' + +// Inlined from core/shared.ts to avoid pulling in semver/std-env via shared chunk +const SHARED_SKILLS_DIR = '.skills' + +// ── Lightweight agent resolution (avoids importing full agent registry) ── + +const AGENT_DIRS = [ + '.claude/skills', + '.cursor/skills', + '.agents/skills', + '.windsurf/skills', + '.cline/skills', + '.github/skills', + '.gemini/skills', + '.goose/skills', + '.roo/skills', + '.opencode/skills', + '.agent/skills', +] + +const AGENT_DIR_MAP: Record = { + 'claude-code': '.claude/skills', + 'cursor': '.cursor/skills', + 'codex': '.agents/skills', + 'windsurf': '.windsurf/skills', + 'cline': '.cline/skills', + 'github-copilot': '.github/skills', + 'gemini-cli': '.gemini/skills', + 'goose': '.goose/skills', + 'roo': '.roo/skills', + 'opencode': '.opencode/skills', + 'amp': '.agents/skills', + 'antigravity': '.agent/skills', +} + +function findSkillsDir(cwd: string): string | null { + const shared = join(cwd, SHARED_SKILLS_DIR) + if (existsSync(shared)) + return shared + + for (const dir of AGENT_DIRS) { + const full = join(cwd, dir) + if (existsSync(join(full, 'skilld-lock.yaml'))) + return full + } + + const configPath = join(homedir(), '.skilld', 'config.yaml') + if (existsSync(configPath)) { + const content = readFileSync(configPath, 'utf-8') + const match = content.match(/^agent:\s*(.+)/m) + if (match) { + const dir = AGENT_DIR_MAP[match[1]!.trim()] + if (dir) + return join(cwd, dir) + } + } + + return null +} + +// ── Main ── + +const cwd = process.cwd() + +if (process.env.CI || process.env.SKILLD_NO_AGENT) + process.exit(0) + +const skillsDir = findSkillsDir(cwd) +if (!skillsDir) + process.exit(0) + +const lock = readLock(skillsDir) +if (!lock || Object.keys(lock.skills).length === 0) + process.exit(0) + +let allIntact = true + +for (const [name, info] of Object.entries(lock.skills)) { + const skillDir = join(skillsDir, name) + if (existsSync(skillDir)) { + if (info.source !== 'shipped') + restorePkgSymlink(skillsDir, name, info, cwd) + continue + } + + allIntact = false + + if (info.source === 'shipped') { + const pkgName = info.packageName || name + const shipped = getShippedSkills(pkgName, cwd, info.version) + const match = shipped.find(s => s.skillName === name) + if (match) + linkShippedSkill(skillsDir, name, match.skillDir) + } +} + +if (allIntact) + process.exit(0) + +// Something was broken; fall back to full CLI for shipped discovery + outdated reporting +const cliPath = resolve(import.meta.dirname, 'cli.mjs') +if (existsSync(cliPath)) { + try { + execFileSync(process.execPath, [cliPath, 'prepare'], { stdio: 'inherit', cwd }) + } + catch {} +} diff --git a/test/unit/prepare-hook.test.ts b/test/unit/prepare-hook.test.ts index dce77a52..f51a361f 100644 --- a/test/unit/prepare-hook.test.ts +++ b/test/unit/prepare-hook.test.ts @@ -4,40 +4,41 @@ import { editJsonProperty } from '../../src/core/package-json.ts' describe('prepare hook script building', () => { const buildPrepare = buildPrepareScript + const standalone = 'skilld prepare || true' it('returns standalone when no existing script', () => { - expect(buildPrepare(undefined)).toBe('skilld prepare') + expect(buildPrepare(undefined)).toBe(standalone) }) it('returns standalone when existing script is empty', () => { - expect(buildPrepare('')).toBe('skilld prepare') - expect(buildPrepare(' ')).toBe('skilld prepare') + expect(buildPrepare('')).toBe(standalone) + expect(buildPrepare(' ')).toBe(standalone) }) - it('appends with && to existing script', () => { - expect(buildPrepare('husky')).toBe('husky && skilld prepare') + it('appends with && and parens to existing script', () => { + expect(buildPrepare('husky')).toBe('husky && (skilld prepare || true)') }) it('handles existing script with multiple commands', () => { - expect(buildPrepare('husky && lint-staged')).toBe('husky && lint-staged && skilld prepare') + expect(buildPrepare('husky && lint-staged')).toBe('husky && lint-staged && (skilld prepare || true)') }) it('strips trailing && from existing script', () => { - expect(buildPrepare('husky &&')).toBe('husky && skilld prepare') - expect(buildPrepare('husky && ')).toBe('husky && skilld prepare') + expect(buildPrepare('husky &&')).toBe('husky && (skilld prepare || true)') + expect(buildPrepare('husky && ')).toBe('husky && (skilld prepare || true)') }) it('strips trailing ; from existing script', () => { - expect(buildPrepare('husky;')).toBe('husky && skilld prepare') + expect(buildPrepare('husky;')).toBe('husky && (skilld prepare || true)') }) it('strips trailing || from existing script', () => { - expect(buildPrepare('husky ||')).toBe('husky && skilld prepare') + expect(buildPrepare('husky ||')).toBe('husky && (skilld prepare || true)') }) it('handles only operators as existing script', () => { - expect(buildPrepare('&&')).toBe('skilld prepare') - expect(buildPrepare(';')).toBe('skilld prepare') + expect(buildPrepare('&&')).toBe(standalone) + expect(buildPrepare(';')).toBe(standalone) }) describe('surgical package.json editing', () => { @@ -49,8 +50,8 @@ describe('prepare hook script building', () => { } } ` - const result = editJsonProperty(raw, ['scripts', 'prepare'], 'skilld prepare') - expect(result).toContain('"prepare": "skilld prepare"') + const result = editJsonProperty(raw, ['scripts', 'prepare'], standalone) + expect(result).toContain(`"prepare": "${standalone}"`) expect(result).toContain('"build": "tsc"') }) @@ -60,9 +61,9 @@ describe('prepare hook script building', () => { } ` let result = editJsonProperty(raw, ['scripts'], {}) - result = editJsonProperty(result, ['scripts', 'prepare'], 'skilld prepare') + result = editJsonProperty(result, ['scripts', 'prepare'], standalone) expect(result).toContain('"scripts"') - expect(result).toContain('"prepare": "skilld prepare"') + expect(result).toContain(`"prepare": "${standalone}"`) expect(result).toContain('"name": "my-pkg"') }) @@ -75,8 +76,8 @@ describe('prepare hook script building', () => { } } ` - const result = editJsonProperty(raw, ['scripts', 'prepare'], 'husky && skilld prepare') - expect(result).toContain('"prepare": "husky && skilld prepare"') + const result = editJsonProperty(raw, ['scripts', 'prepare'], 'husky && (skilld prepare || true)') + expect(result).toContain('"prepare": "husky && (skilld prepare || true)"') expect(result).toContain('"build": "tsc"') expect(result).toContain('"name": "my-pkg"') })