Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"prepare": "test -z \"$CI\" && skilld prepare || true" relies on POSIX test and shell semantics that won’t work under npm’s default Windows shell (cmd.exe). If Windows development is supported, consider making this script shell-agnostic (e.g. a small node -e gate) or dropping the test ... && and relying on skilld prepare itself to no-op in CI.

Suggested change
"prepare": "test -z \"$CI\" && skilld prepare || true"
"prepare": "skilld prepare"

Copilot uses AI. Check for mistakes.
},
"dependencies": {
"@clack/prompts": "catalog:deps",
Expand Down
57 changes: 4 additions & 53 deletions src/cache/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/cli-entry.ts
Original file line number Diff line number Diff line change
@@ -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',
)
Comment on lines +8 to +11

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await import(<ternary>) uses a non-literal specifier, which bundlers typically can’t statically analyze/rewire (especially when moving from .ts sources to .mjs output). This risks dist/cli-entry.mjs trying to import ./prepare.ts / ./cli.ts at runtime. Prefer an if/else with two separate import('./prepare.ts') and import('./cli.ts') calls so the specifiers remain string literals.

Suggested change
await import(process.argv[2] === 'prepare' && process.argv.length <= 3
? './prepare.ts'
: './cli.ts',
)
if (process.argv[2] === 'prepare' && process.argv.length <= 3) {
await import('./prepare.ts')
}
else {
await import('./cli.ts')
}

Copilot uses AI. Check for mistakes.
7 changes: 4 additions & 3 deletions src/cli-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,17 +497,18 @@ export async function suggestPrepareHook(cwd: string = process.cwd()): Promise<b
* Build the full prepare script value, safely appending to any existing command.
*/
export function buildPrepareScript(existing: string | undefined): string {
const cmd = 'skilld prepare || true'
if (!existing || !existing.trim())
return 'skilld prepare'
return cmd

const trimmed = existing.trim()

// Strip trailing && or ; that would leave a dangling operator
const cleaned = trimmed.replace(/[&|;]+\s*$/, '').trim()
if (!cleaned)
return 'skilld prepare'
return cmd

return `${cleaned} && skilld prepare`
return `${cleaned} && (${cmd})`
}

export function getRepoHint(name: string, cwd: string): string | undefined {
Expand Down
83 changes: 29 additions & 54 deletions src/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@
* 3. Report outdated skills count and suggest `skilld update`
*/

import type { SkillInfo } from '../core/lockfile.ts'
import { existsSync, mkdirSync, symlinkSync } from 'node:fs'
import { existsSync, mkdirSync } from 'node:fs'
import * as p from '@clack/prompts'
import { defineCommand } from 'citty'
import { join } from 'pathe'
import { agents, linkSkillToAgents } from '../agent/index.ts'
import { getShippedSkills, linkShippedSkill, resolvePkgDir } from '../cache/index.ts'
import { resolveAgent } from '../cli-helpers.ts'
import { mergeLocks, readLock, writeLock } from '../core/lockfile.ts'
import { readLock, writeLock } from '../core/lockfile.ts'
import { getShippedSkills, linkShippedSkill, restorePkgSymlink } from '../core/prepare.ts'
import { getSharedSkillsDir } from '../core/shared.ts'
import { getProjectState } from '../core/skills.ts'

Expand All @@ -40,40 +39,42 @@ export const prepareCommandDef = defineCommand({
const shared = getSharedSkillsDir(cwd)
const skillsDir = shared || join(cwd, agentConfig.skillsDir)

// ── 1. Restore broken symlinks from lockfile ──
// ── Fast path: read primary lockfile, check all skills intact ──

const allSkillsDirs = shared
? [shared]
: Object.values(agents).map(t => join(cwd, t.skillsDir))
const allLocks = allSkillsDirs
.map(dir => readLock(dir))
.filter((l): l is NonNullable<typeof l> => !!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)
Comment on lines +72 to +73

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new early return on allIntact skips the slow path entirely, which means prepare no longer discovers/installs new shipped skills from newly added dependencies, and also skips outdated reporting. This is a behavior change versus the command’s docstring (steps 2/3) and can leave projects out of sync after dependency changes unless users run a different command. Consider gating this fast-path return behind an explicit flag/env (hook-only), or adding a cheap β€œdeps changed” check so shipped-skill discovery still runs when needed.

Suggested change
// If all skills intact, skip expensive getProjectState entirely
if (allIntact)
// If all skills intact and fast-path is explicitly enabled, skip expensive getProjectState entirely
const fastPrepare = process.env.SKILLD_PREPARE_FAST === '1' || process.env.SKILLD_PREPARE_FAST === 'true'
if (allIntact && fastPrepare)

Copilot uses AI. Check for mistakes.
return
}

// ── 2. Auto-install shipped skills from deps ──
// ── Slow path: discover new shipped skills + report outdated ──

const state = await getProjectState(cwd)
let shippedCount = 0
Expand Down Expand Up @@ -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)
}
79 changes: 79 additions & 0 deletions src/core/prepare.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +45 to +47

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restorePkgSymlink creates a directory symlink without specifying a type. On Windows, this can require elevated privileges and is inconsistent with the rest of the codebase (e.g. linkPkg uses 'junction'). Consider using 'junction' (or a small helper) for directory links here to avoid permission-related failures during prepare.

Copilot uses AI. Check for mistakes.

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)

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linkShippedSkill also creates a directory symlink without specifying a type. If Windows support is expected, this can fail unless Developer Mode/elevation is enabled; other symlinks in the project use 'junction' for directories. Aligning this call with the existing 'junction' pattern would make shipped-skill linking more reliable.

Suggested change
symlinkSync(targetDir, linkPath)
symlinkSync(targetDir, linkPath, 'junction')

Copilot uses AI. Check for mistakes.
}
Loading
Loading