Skip to content

perf(cli): remaining slow startup paths after #2025, plus guardrails #2026

Description

@TabishB

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Make the startup test cover every command. Have startup-modules.test.ts fail when a new command is added without a case.
  2. Widen the lint rule. Ban static imports of command implementations and heavy packages (zod, yaml, fast-glob, ora, diff, @InQuirer) from src/cli/**.
  3. 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.
  4. 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

  1. Items 1 and 2: Tab completion gets the most noticeable speedup.
  2. Items 3, 4 and 5: Desktop.
  3. Items 6 and 7: agents.
  4. Guardrails 1 to 3, best done together with the fixes.

Activity

  1. ryandemelo commented on Oct 9, 2026

    @ryandemelo
    Contributor

    I'll take items 1 and 2, the Tab completion path: no telemetry for __complete, and listSchemas and ora loaded only where they're used. Not waiting on telemetry for the other commands changes how events get delivered, so I'll keep that to its own PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions