Summary
Every time the CLI runs, it loads code it doesn't need for that command. #2025 fixes the biggest case: openspec --version now loads 24 modules instead of 485. Other commands still do extra work, though, and some of them run a lot:
- Shell completion runs the CLI on every Tab press.
- OpenSpec Desktop runs
list --json, doctor --json, store list --json and config list again and again.
- Agents call
status --json and instructions --json many times per change.
This issue lists what's left, explains how the CLI got slow, and proposes guardrails so it doesn't happen again. Nothing here blocks #2025.
What's still slow
Times are medians on macOS with Node 23. A bare node process takes 19 ms. On Windows, expect every number to be several times higher. File paths and line numbers are as of #2025.
| # |
Problem |
Who pays |
Cost now |
After fix |
| 1 |
Telemetry waits for its network request to finish before the CLI exits |
Every command once telemetry is on, including Tab completion |
+200 to 340 ms per command |
~0 ms |
| 2 |
Tab completion loads zod, yaml, fast-glob and ora, which it doesn't need |
Every Tab press |
345 modules, 113 ms |
55 modules, 43 ms |
| 3 |
The store code loads yaml and zod as soon as it's imported |
store list, doctor, every command run inside a project |
111 ms |
57 ms |
| 4 |
config path/list/get load zod, but only set and edit validate |
Desktop config calls |
59 ms |
40 ms |
| 5 |
list re-reads and re-parses the schema YAML once per change |
list --json, view (gets slower as changes pile up) |
142 ms (61 changes) |
113 ms |
| 6 |
Importing from core/artifact-graph/index.ts also pulls in fast-glob |
list, schemas, completion |
74 extra modules, ~18 ms |
0 |
| 7 |
ora (the spinner) is loaded even with --json, which never shows a spinner |
status, instructions, validate, new change, templates, schema |
23 extra modules, ~19 ms |
0 |
Where each one lives and how to fix it
- Telemetry delays exit.
src/cli/index.ts:227 awaits shutdown(), which waits for the fetch in src/telemetry/index.ts (timeout 1000 ms). Fix: don't track the hidden __complete command at all. For other commands, stop waiting on the request before exiting, either by capping the wait or by sending it from a detached process.
- Completion.
src/core/completions/completion-provider.ts:2 imports listSchemas from the artifact-graph/index.js barrel (a file that re-exports a whole folder), and src/commands/completion.ts:1 imports ora at the top of the file. Fix: load listSchemas from resolver.js with await import() inside getSchemaNames, and load ora only in install and uninstall.
- Store.
src/core/store/foundation.ts:3-4 imports yaml and zod and builds its zod schemas at the top of the file. Fix: load yaml and zod inside the functions that read the registry and metadata. Those functions are already async.
- Config.
src/commands/config.ts imports core/config-schema.ts, which builds GlobalConfigSchema with zod as soon as it loads. Fix: move the schema and validateConfig into their own module and load it only from set, edit and reset.
- Schema re-parsing.
resolveSchema in src/core/artifact-graph/resolver.ts has no cache. Fix: cache the result for the life of the process, keyed by schema name and project root.
- fast-glob.
src/core/artifact-graph/outputs.ts:3 imports fast-glob at the top of the file, and the barrel re-exports outputs.ts. Fix: load fast-glob only where it's used, and import resolver.js and outputs.js directly instead of through the barrel.
- ora. There's a top-level
import ora in src/commands/workflow/{status,instructions,new-change,templates}.ts, src/commands/validate.ts and src/commands/schema.ts. Fix: await import('ora') only when not in --json mode.
Two small follow-ups from reviewing #2025:
test/cli-e2e/startup-modules.test.ts never checks the CLI's exit code. A --version run that crashed right after loading commander would still pass. Fix: assert status === 0 for --version and --help.
- On a broken install, the new lazy imports run outside each command's own error handling.
doctor --json, context --json and status --all --json (src/cli/index.ts:726) would print a stack trace instead of their JSON error. The exit code is still 1.
How we got here
--version load cost across published releases (macOS, Node 23):
| Version |
Date |
Modules |
--version |
| 0.1.0 |
2025-09 |
239 |
108 ms |
| 0.17.0 |
2025-12 |
267 |
108 ms |
| 0.20.0 |
2026-01 |
471 |
135 ms |
| 1.4.0 |
2026-03 |
547 |
149 ms |
| 1.14.0 |
2026-09 |
485 |
177 ms |
Why nothing caught it
How to prevent it
- Make the startup test cover every command. Have
startup-modules.test.ts fail when a new command is added without a case.
- Widen the lint rule. Ban static imports of command implementations and heavy packages (zod, yaml, fast-glob, ora, diff, @InQuirer) from
src/cli/**.
- Add a module budget to CI. For example,
--version may load at most 30 modules. Unlike timing, this gives the same result on every run.
- Check startup before raising a timeout. When a Windows test times out, measure how long the CLI takes to start before raising the limit. Add this to CONTRIBUTING.
Suggested order
- Items 1 and 2: Tab completion gets the most noticeable speedup.
- Items 3, 4 and 5: Desktop.
- Items 6 and 7: agents.
- Guardrails 1 to 3, best done together with the fixes.
Summary
Every time the CLI runs, it loads code it doesn't need for that command. #2025 fixes the biggest case:
openspec --versionnow loads 24 modules instead of 485. Other commands still do extra work, though, and some of them run a lot:list --json,doctor --json,store list --jsonandconfig listagain and again.status --jsonandinstructions --jsonmany times per change.This issue lists what's left, explains how the CLI got slow, and proposes guardrails so it doesn't happen again. Nothing here blocks #2025.
What's still slow
Times are medians on macOS with Node 23. A bare
nodeprocess takes 19 ms. On Windows, expect every number to be several times higher. File paths and line numbers are as of #2025.store list,doctor, every command run inside a projectconfig path/list/getload zod, but onlysetandeditvalidatelistre-reads and re-parses the schema YAML once per changelist --json,view(gets slower as changes pile up)core/artifact-graph/index.tsalso pulls in fast-globlist,schemas, completionora(the spinner) is loaded even with--json, which never shows a spinnerstatus,instructions,validate,new change,templates,schemaWhere each one lives and how to fix it
src/cli/index.ts:227awaitsshutdown(), which waits for thefetchinsrc/telemetry/index.ts(timeout 1000 ms). Fix: don't track the hidden__completecommand at all. For other commands, stop waiting on the request before exiting, either by capping the wait or by sending it from a detached process.src/core/completions/completion-provider.ts:2importslistSchemasfrom theartifact-graph/index.jsbarrel (a file that re-exports a whole folder), andsrc/commands/completion.ts:1importsoraat the top of the file. Fix: loadlistSchemasfromresolver.jswithawait import()insidegetSchemaNames, and loadoraonly in install and uninstall.src/core/store/foundation.ts:3-4imports yaml and zod and builds its zod schemas at the top of the file. Fix: load yaml and zod inside the functions that read the registry and metadata. Those functions are already async.src/commands/config.tsimportscore/config-schema.ts, which buildsGlobalConfigSchemawith zod as soon as it loads. Fix: move the schema andvalidateConfiginto their own module and load it only fromset,editandreset.resolveSchemainsrc/core/artifact-graph/resolver.tshas no cache. Fix: cache the result for the life of the process, keyed by schema name and project root.src/core/artifact-graph/outputs.ts:3imports fast-glob at the top of the file, and the barrel re-exportsoutputs.ts. Fix: load fast-glob only where it's used, and importresolver.jsandoutputs.jsdirectly instead of through the barrel.import orainsrc/commands/workflow/{status,instructions,new-change,templates}.ts,src/commands/validate.tsandsrc/commands/schema.ts. Fix:await import('ora')only when not in--jsonmode.Two small follow-ups from reviewing #2025:
test/cli-e2e/startup-modules.test.tsnever checks the CLI's exit code. A--versionrun that crashed right after loading commander would still pass. Fix: assertstatus === 0for--versionand--help.doctor --json,context --jsonandstatus --all --json(src/cli/index.ts:726) would print a stack trace instead of their JSON error. The exit code is still 1.How we got here
--versionload cost across published releases (macOS, Node 23):--versionsrc/cli/index.ts. Every later command followed that example, and the list of top-level imports grew from 1 to 23.initand@inquirer/promptsload lazily to fix a hang. It added a lint rule, but that rule covers only@inquirer/*. The general rule, "keep heavy dependencies out of startup", was never written down.register*Commandfiles held both a command's definition and its implementation. Loading--helptherefore loaded every command's full dependency tree. perf(cli): load each command's implementation only when it runs #2025 splits those files.Why nothing caught it
--versioncheck with a 5 s deadline, and takes about 600 ms per call on Windows.How to prevent it
startup-modules.test.tsfail when a new command is added without a case.src/cli/**.--versionmay load at most 30 modules. Unlike timing, this gives the same result on every run.Suggested order