-
Notifications
You must be signed in to change notification settings - Fork 9
perf: fast-path skilld prepare (~40ms vs ~200ms)
#49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||||||||||||||||||||||
| 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') | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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' | ||||||||||||
|
|
||||||||||||
|
|
@@ -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
|
||||||||||||
| // 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) |
| 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
|
||||||
|
|
||||||
| 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) | ||||||
|
||||||
| symlinkSync(targetDir, linkPath) | |
| symlinkSync(targetDir, linkPath, 'junction') |
There was a problem hiding this comment.
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 POSIXtestand 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 smallnode -egate) or dropping thetest ... &&and relying onskilld prepareitself to no-op in CI.