Skip to content

perf: fast-path skilld prepare (~40ms vs ~200ms)#49

Merged
harlan-zw merged 1 commit intomainfrom
perf/fast-prepare
Mar 24, 2026
Merged

perf: fast-path skilld prepare (~40ms vs ~200ms)#49
harlan-zw merged 1 commit intomainfrom
perf/fast-prepare

Conversation

@harlan-zw
Copy link
Copy Markdown
Collaborator

@harlan-zw harlan-zw commented Mar 24, 2026

❓ Type of change

  • 👌 Enhancement

📚 Description

The skilld prepare hook ran through the full CLI bootstrap (citty, clack, agent registry, all command imports) adding ~200ms of module loading before any prepare logic executed. On the common "nothing changed" path, the prepare logic itself was nearly free.

This adds a thin cli-entry.ts that intercepts skilld prepare and routes to a lightweight prepare.ts entry point. Shared utilities (resolvePkgDir, restorePkgSymlink, getShippedSkills, linkShippedSkill) are extracted to core/prepare.ts so both the fast and full paths use the same code. cache/storage.ts re-exports these for backwards compatibility.

Also updates buildPrepareScript to emit skilld prepare || true with proper parenthesization when appending to existing scripts, ensuring CI doesn't break when skilld isn't installed.

Benchmarks (5 runs, warm cache):

Path Time
skilld prepare (before) ~210ms
skilld prepare (after, fast path) ~40ms
skilld prepare --agent x (full CLI fallback) ~150ms

Summary by CodeRabbit

  • Performance

    • Added fast-path optimization for the prepare command that verifies skill integrity upfront, avoiding unnecessary full CLI pipeline invocations when skills are intact.
  • Improvements

    • Prepare hook is now CI-aware and will gracefully skip prepare operations in continuous integration environments without failing.
  • Chores

    • Reorganized CLI entry points and updated build configuration.

Add lightweight cli-entry that intercepts `skilld prepare` before the
full CLI module tree loads. Extract shared prepare utilities to
core/prepare.ts to eliminate duplication between fast and full paths.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

This PR introduces a refactored npm prepare hook system featuring a new lightweight CLI entry point (cli-entry.ts) that conditionally routes between a fast-path prepare handler and the full CLI, centralizes prepare utilities into a new shared core module (core/prepare.ts), and updates prepare script generation to be CI-aware and failure-tolerant.

Changes

Cohort / File(s) Summary
New CLI Entry & Fast-Path Prepare
src/cli-entry.ts, src/prepare.ts
Introduces conditional CLI routing (prepare command fast-path vs. full CLI) and a lightweight npm prepare hook that restores package symlinks and shipped skills, falling back to full CLI only if needed.
Core Prepare Utilities
src/core/prepare.ts
New shared module exporting resolvePkgDir, restorePkgSymlink, getShippedSkills, linkShippedSkill, and the ShippedSkill interface for centralized prepare logic.
Prepare Utility Re-exports
src/cache/storage.ts
Re-exports resolvePkgDir, getShippedSkills, linkShippedSkill, and ShippedSkill from core/prepare.ts instead of local definitions; removes 53 lines of local implementation.
Prepare Command Refactor
src/commands/prepare.ts
Refactors control flow to implement fast-path checking (early return if all skills intact), delegates to core utilities for symlink restoration and shipped skill linking, reducing expensive getProjectState calls.
CLI Helper Updates
src/cli-helpers.ts
Updates buildPrepareScript to wrap prepare commands with `
Build & Package Configuration
build.config.ts, package.json
Adds new source files to build inputs; updates CLI entry point from ./dist/cli.mjs to ./dist/cli-entry.mjs; makes prepare script CI-aware with test -z "$CI" && skilld prepare \|\| true.
Test Updates
test/unit/prepare-hook.test.ts
Updates test expectations to reflect new prepare script format with `

Sequence Diagram(s)

sequenceDiagram
    participant npm as npm prepare hook
    participant entry as cli-entry.ts
    participant prep as prepare.ts
    participant core as core/prepare.ts
    participant fs as Filesystem
    participant cli as cli.ts
    
    npm->>entry: Invoke (process.argv: prepare)
    entry->>entry: Check argv for "prepare" command
    alt prepare command detected
        entry->>prep: Dynamic import prepare.ts
        prep->>fs: Resolve local skills directory
        prep->>fs: Read skill lockfile
        prep->>core: resolvePkgDir() / restorePkgSymlink()
        prep->>fs: Check skill directory existence
        alt All skills intact
            prep-->>npm: Exit 0 (fast-path success)
        else Skills missing/broken
            prep->>core: getShippedSkills() / linkShippedSkill()
            prep->>fs: Restore missing shipped skills
            alt Still incomplete
                prep->>cli: Fallback: execFileSync cli.mjs prepare
                cli-->>npm: Return exit code
            else Restored successfully
                prep-->>npm: Exit 0
            end
        end
    else Other command
        entry->>cli: Dynamic import cli.ts
        cli-->>npm: Handle full CLI
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 A fast-path for hopping, symlinks to mend,
Core utilities shared from end to end,
Prepare hooks now skip the long CLI way,
When all skills are intact—hop away!
Failures forgiven with || true to stay.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'perf: fast-path skilld prepare (~40ms vs ~200ms)' accurately summarizes the main objective: adding a performance optimization that reduces prepare hook execution time from ~200ms to ~40ms through a fast-path implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/fast-prepare

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (5)
src/prepare.ts (3)

118-121: Empty catch block silently swallows fallback errors.

The empty catch {} on Lines 120-121 hides any errors from the full CLI fallback. While the || true pattern in the npm script provides resilience, completely silencing errors here makes debugging difficult if the fallback consistently fails.

💡 Log errors in debug mode or to stderr
   try {
     execFileSync(process.execPath, [cliPath, 'prepare'], { stdio: 'inherit', cwd })
   }
-  catch {}
+  catch (err) {
+    // Fallback failed; suppress for CI resilience but log for debugging
+    if (process.env.DEBUG)
+      console.error('[skilld prepare] fallback failed:', err)
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/prepare.ts` around lines 118 - 121, The empty catch after calling
execFileSync with process.execPath and cliPath for the 'prepare' fallback
swallows errors; update the catch block to surface failures by logging the
caught error (e.g., console.error or an existing logger) and include context
(that execFileSync(process.execPath, [cliPath, 'prepare'], { stdio: 'inherit',
cwd }) failed), and only suppress the error when explicitly intended (e.g.,
rethrow or conditionally ignore in non-debug mode); reference execFileSync,
process.execPath, cliPath, 'prepare', and cwd to locate and modify the
try/catch.

62-71: Fragile YAML parsing via regex.

The regex content.match(/^agent:\s*(.+)/m) for parsing the config file doesn't handle edge cases like quoted values, inline comments, or trailing whitespace in the value. While unlikely in practice, this could lead to incorrect agent resolution.

💡 More robust parsing
   if (existsSync(configPath)) {
     const content = readFileSync(configPath, 'utf-8')
-    const match = content.match(/^agent:\s*(.+)/m)
+    const match = content.match(/^agent:\s*["']?([^"'\s#]+)["']?/m)
     if (match) {
-      const dir = AGENT_DIR_MAP[match[1]!.trim()]
+      const dir = AGENT_DIR_MAP[match[1]!]
       if (dir)
         return join(cwd, dir)
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/prepare.ts` around lines 62 - 71, The current fragile parsing in
prepare.ts uses a regex content.match(/^agent:\s*(.+)/m) to read the agent from
the YAML at configPath which fails for quoted values, inline comments, or
trailing whitespace; replace this with a proper YAML parse step (e.g., use a
YAML parser to parse readFileSync(configPath) into an object and read
obj.agent), then map that value via AGENT_DIR_MAP and return join(cwd, dir) if
found (update any function or code paths referencing configPath, the regex
match, and AGENT_DIR_MAP to use the parsed agent value instead).

17-49: Potential maintenance burden: duplicated agent directory mappings.

AGENT_DIRS and AGENT_DIR_MAP duplicate the agent configuration from the main registry (src/agent/index.ts). If new agents are added or paths change, these must be updated in two places.

Consider adding a comment noting this duplication, or generating these constants at build time from the canonical source.

💬 Suggested documentation
 // 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) ──
+// ── Lightweight agent resolution (avoids importing full agent registry) ──
+// NOTE: Keep AGENT_DIRS and AGENT_DIR_MAP in sync with src/agent/index.ts
+// This duplication is intentional to avoid ~200ms of module loading.
 
 const AGENT_DIRS = [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/prepare.ts` around lines 17 - 49, AGENT_DIRS and AGENT_DIR_MAP in
prepare.ts duplicate the canonical agent registry and create a maintenance
burden; either add a clear TODO comment above the AGENT_DIRS/AGENT_DIR_MAP
declarations calling out the duplication and linking to the canonical source
(e.g., src/agent/index.ts), or replace these in build by generating them from
the canonical registry (generate at build-time and import the result); refer to
the AGENT_DIRS and AGENT_DIR_MAP symbols when making the change so future
maintainers know where to update or how generation occurs.
src/core/prepare.ts (1)

69-79: Consider handling symlink creation errors on Windows.

The symlinkSync call on Line 78 may fail on Windows without administrator privileges or Developer Mode enabled. While this is an edge case, the function silently assumes symlink creation will succeed.

💡 Optional: wrap symlink creation with error handling
 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)
+  try {
+    symlinkSync(targetDir, linkPath)
+  }
+  catch (err) {
+    // Windows may require elevated privileges for symlinks
+    if ((err as NodeJS.ErrnoException).code === 'EPERM')
+      throw new Error(`Cannot create symlink at ${linkPath}. On Windows, enable Developer Mode or run as Administrator.`)
+    throw err
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/prepare.ts` around lines 69 - 79, The symlink creation in
linkShippedSkill can fail on Windows; wrap the symlinkSync(targetDir, linkPath)
call in a try/catch inside linkShippedSkill and on error (e.g., EPERM or
OS-specific failures) fall back to creating a replica of the target using a
recursive copy (e.g., fs.cpSync or equivalent) to linkPath, making sure to still
remove any existing path via unlinkSync/lstatSync or rmSync before the fallback
and rethrow unexpected errors after logging or annotating them.
src/cache/storage.ts (1)

8-8: Move these shared helpers into a prepare-agnostic module.

Line 8 and Lines 126-128 make src/cache/storage.ts depend on src/core/prepare.ts for generic package-resolution helpers. That works, but it weakens module boundaries and makes this cache layer sensitive to future prepare-only imports. I’d put resolvePkgDir, getShippedSkills, and linkShippedSkill in a neutral shared module and have both storage and the prepare entrypoints depend on that instead.

Also applies to: 126-128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cache/storage.ts` at line 8, The cache module currently imports
prepare-only helpers (resolvePkgDir, getShippedSkills, linkShippedSkill) from
src/core/prepare.ts, which couples storage to the prepare layer; move these
three functions into a new neutral shared module (e.g.,
src/shared/pkgHelpers.ts) and update imports so storage.ts and prepare.ts both
import resolvePkgDir, getShippedSkills, and linkShippedSkill from that shared
module; ensure the exported signatures remain identical and update any relative
import paths in storage.ts (and the prepare entrypoint) to reference the new
shared module.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/cache/storage.ts`:
- Line 8: The cache module currently imports prepare-only helpers
(resolvePkgDir, getShippedSkills, linkShippedSkill) from src/core/prepare.ts,
which couples storage to the prepare layer; move these three functions into a
new neutral shared module (e.g., src/shared/pkgHelpers.ts) and update imports so
storage.ts and prepare.ts both import resolvePkgDir, getShippedSkills, and
linkShippedSkill from that shared module; ensure the exported signatures remain
identical and update any relative import paths in storage.ts (and the prepare
entrypoint) to reference the new shared module.

In `@src/core/prepare.ts`:
- Around line 69-79: The symlink creation in linkShippedSkill can fail on
Windows; wrap the symlinkSync(targetDir, linkPath) call in a try/catch inside
linkShippedSkill and on error (e.g., EPERM or OS-specific failures) fall back to
creating a replica of the target using a recursive copy (e.g., fs.cpSync or
equivalent) to linkPath, making sure to still remove any existing path via
unlinkSync/lstatSync or rmSync before the fallback and rethrow unexpected errors
after logging or annotating them.

In `@src/prepare.ts`:
- Around line 118-121: The empty catch after calling execFileSync with
process.execPath and cliPath for the 'prepare' fallback swallows errors; update
the catch block to surface failures by logging the caught error (e.g.,
console.error or an existing logger) and include context (that
execFileSync(process.execPath, [cliPath, 'prepare'], { stdio: 'inherit', cwd })
failed), and only suppress the error when explicitly intended (e.g., rethrow or
conditionally ignore in non-debug mode); reference execFileSync,
process.execPath, cliPath, 'prepare', and cwd to locate and modify the
try/catch.
- Around line 62-71: The current fragile parsing in prepare.ts uses a regex
content.match(/^agent:\s*(.+)/m) to read the agent from the YAML at configPath
which fails for quoted values, inline comments, or trailing whitespace; replace
this with a proper YAML parse step (e.g., use a YAML parser to parse
readFileSync(configPath) into an object and read obj.agent), then map that value
via AGENT_DIR_MAP and return join(cwd, dir) if found (update any function or
code paths referencing configPath, the regex match, and AGENT_DIR_MAP to use the
parsed agent value instead).
- Around line 17-49: AGENT_DIRS and AGENT_DIR_MAP in prepare.ts duplicate the
canonical agent registry and create a maintenance burden; either add a clear
TODO comment above the AGENT_DIRS/AGENT_DIR_MAP declarations calling out the
duplication and linking to the canonical source (e.g., src/agent/index.ts), or
replace these in build by generating them from the canonical registry (generate
at build-time and import the result); refer to the AGENT_DIRS and AGENT_DIR_MAP
symbols when making the change so future maintainers know where to update or how
generation occurs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d9fdf9b-806a-4c6e-8749-a24416f84861

📥 Commits

Reviewing files that changed from the base of the PR and between 3badd14 and 2030194.

📒 Files selected for processing (9)
  • build.config.ts
  • package.json
  • src/cache/storage.ts
  • src/cli-entry.ts
  • src/cli-helpers.ts
  • src/commands/prepare.ts
  • src/core/prepare.ts
  • src/prepare.ts
  • test/unit/prepare-hook.test.ts

@harlan-zw harlan-zw merged commit b0a32c6 into main Mar 24, 2026
5 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes skilld prepare by introducing a lightweight entry path that avoids loading the full CLI on the common no-op case, and refactors shared “prepare” utilities so both the fast path and full command can reuse them.

Changes:

  • Add a new cli-entry that intercepts skilld prepare and routes to a fast prepare entry point.
  • Extract shared prepare utilities into src/core/prepare.ts and re-export them from cache/storage.ts for compatibility.
  • Update buildPrepareScript (and tests) to use skilld prepare || true and parenthesize when appending.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
test/unit/prepare-hook.test.ts Updates expectations for the new prepare script string and parenthesized append behavior.
src/prepare.ts New ultra-fast prepare entry point with a fallback to the full CLI.
src/core/prepare.ts New shared helpers for resolving pkg dirs, restoring pkg symlinks, and shipped-skill linking.
src/commands/prepare.ts Refactors command to use shared helpers and introduces an early-return fast path.
src/cli-helpers.ts Changes buildPrepareScript to emit `skilld prepare
src/cli-entry.ts New bin entry that routes to fast prepare for skilld prepare.
src/cache/storage.ts Re-exports moved prepare utilities from src/core/prepare.ts.
package.json Points bin to cli-entry and adjusts this repo’s prepare script.
build.config.ts Adds cli-entry.ts and prepare.ts to bundle inputs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +112 to +115
if (allIntact)
process.exit(0)

// Something was broken; fall back to full CLI for shipped discovery + outdated reporting
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

In the fast entry point, exiting when allIntact is true means skilld prepare will never discover/install newly shipped skills that aren’t already in the lockfile (e.g. after adding a new dependency that ships skills). The fallback only triggers when an existing locked skill dir is missing. If shipped-skill discovery is still desired in prepare, consider triggering the full CLI when deps/lockfiles changed (e.g. compare lockfile mtimes or hash) or provide a way to force the full path.

Suggested change
if (allIntact)
process.exit(0)
// Something was broken; fall back to full CLI for shipped discovery + outdated reporting
if (allIntact && !process.env.SKILLD_PREPARE_FULL)
process.exit(0)
// Something was broken or full path was explicitly requested; fall back to full CLI for shipped discovery + outdated reporting

Copilot uses AI. Check for mistakes.
Comment on lines +118 to +121
try {
execFileSync(process.execPath, [cliPath, 'prepare'], { stdio: 'inherit', cwd })
}
catch {}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The fallback to the full CLI swallows all errors (catch {}), so if the full prepare fails the command exits silently with whatever status follows. This can make real breakage hard to diagnose (especially when users run skilld prepare manually). Consider propagating the error/exit code by default and only suppressing it when explicitly running as a package.json hook (e.g. via an env var).

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +11
await import(process.argv[2] === 'prepare' && process.argv.length <= 3
? './prepare.ts'
: './cli.ts',
)
Copy link

Copilot AI Mar 24, 2026

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.
"release": "pnpm build && bumpp -x \"npx changelogen --output=CHANGELOG.md\"",
"prepack": "pnpm run build",
"prepare": "skilld prepare"
"prepare": "test -z \"$CI\" && skilld prepare || true"
Copy link

Copilot AI Mar 24, 2026

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.
Comment on lines +45 to +47
mkdirSync(refsDir, { recursive: true })
symlinkSync(pkgDir, pkgLink)
}
Copy link

Copilot AI Mar 24, 2026

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.
unlinkSync(linkPath)
else rmSync(linkPath, { recursive: true, force: true })
}
symlinkSync(targetDir, linkPath)
Copy link

Copilot AI Mar 24, 2026

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.
Comment on lines +72 to +73
// If all skills intact, skip expensive getProjectState entirely
if (allIntact)
Copy link

Copilot AI Mar 24, 2026

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants