with one click
cli-maintenance
// Checklist and decision tree for adding, modifying, or removing CLI commands. Keeps CLI source, slash commands, docs, and skills in sync.
// Checklist and decision tree for adding, modifying, or removing CLI commands. Keeps CLI source, slash commands, docs, and skills in sync.
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | cli-maintenance |
| description | Checklist and decision tree for adding, modifying, or removing CLI commands. Keeps CLI source, slash commands, docs, and skills in sync. |
| requires | {"scripts":["tools/scripts/cli_sync_check.py"]} |
tools/cli/cmd_<name>.cpp with int cmd_<name>(const std::vector<std::string>& args)tools/cli/cli_common.hpp in the command forward declarations sectiontools/cli/pulp_cli.cpp (Command, ScriptCommand, or BinaryCommand)tools/cli/CMakeLists.txt to compile the new filedocs/status/cli-commands.yaml with:
name, status (use status vocabulary: stable/usable/experimental), summaryargs with name, kind (positional/option/flag/passthrough), descriptionsubcommands if applicableCreate a slash command (.claude/commands/<name>.md) if:
/name shortcutSkip a slash command if:
cache clean)Commands that intentionally don't have slash commands: audio, cache, clean, export-tokens, ci-local, design-debug, harness, help, projects, project
Commands that DO have slash commands (list for cross-reference, not exhaustive — ls .claude/commands/ is authoritative):
build, test, run, validate, ship, version, doctor, create, docs, status, design, import-design, inspect, pr, ci, upgrade, prototype-loop
docs/reference/cli.mddocs/reference/capabilities.mddocs/guides/claude-code-plugin.md command tablegrep -r "pulp <name>" .agents/skills/ — update any skill that calls this commandpython3 tools/scripts/cli_sync_check.py
Same as above, focus on steps 2, 4, 5, 6, 7. Key risks:
cli-commands.yaml.mdAfter the v0.78.1 cutover, the user-facing CLI is Rust pulp. Release
archives install pulp plus sibling pulp-cpp, and source builds stage the
Rust binary at ./build/pulp. Slash-command examples should prefer pulp
on PATH for installed users and ./build/pulp for source-tree examples.
Do not point new docs at ./build/tools/cli/pulp; that path was the old
C++ default. Use pulp-cpp only when documenting fallthrough, rollback, or
debug comparisons.
BinaryCommand entries in tools/cli/pulp_cli.cpp delegate to helper binaries
inside the active build tree, e.g. pulp import-design launches
tools/import-design/pulp-import-design. Do not hard-code build/ for these
lookups: CI uses matrix-scoped directories such as build-linux,
build-macos, and build-windows, and self-hosted macOS can mask mistakes
with stale warm build/ artifacts. Resolve helpers from the running CLI's
sibling build tree first, honor PULP_BUILD_DIR when present, and keep
test/test_cli_shellout.cpp subprocess-output INFO(...) diagnostics so
future failures show the delegated binary's stderr.
--from <source>)When extending a flag that takes one of a fixed set of values
(pulp import-design --from {figma,stitch,v0,pencil,claude,...},
pulp validate --target {auval,clap-validator,pluginval,...}, etc.),
all four surfaces have to land in the same PR or the skill-sync gate
trips:
tools/import-design/pulp_import_design.cpp,
etc.) — accept the new value in the parser and the dispatch..claude/commands/ — usage block,
examples, and any "when to detect this" notes..agents/skills/*/SKILL.md — same prose,
kept in sync so the agent's pre-context matches what the slash
command says.tools/scripts/skill_path_map.json) — add the new
header/source paths if the new source brings new C++ files.Reason this gets called out: editing .claude/commands/<name>.md maps
to the cli-maintenance skill (because the path map owns
.claude/commands/**), not to the topical skill. So a diff that
updates the slash command + the topical skill but leaves
cli-maintenance untouched still trips skill-sync. Keep this section
present so the gate stays satisfiable by the topical edit alone.
pulp #709 / --from claude is the worked example.
cmd_*.cpp and command table in pulp_cli.cppcli_common.hpp declarationsCMakeLists.txtcli-commands.yamldocs/reference/cli.mdgrep -r "pulp <name>" .agents/skills/pulp project bump shell helperstools/cli/cmd_project.cpp shells out to git / cmake from unit-tested
helper paths, including Windows CI. Keep output redirection platform-aware:
POSIX uses /dev/null, but Windows cmd.exe needs NUL. Do not add raw
2>/dev/null or >/dev/null 2>&1 in this file; route new call sites through
the local null-redirection helpers instead. Otherwise Windows Namespace can
misreport clean/dirty git state or fail origin-main probes even though the
same tests pass on macOS/Linux.
pulp pr — shim over shipyard prBy default pulp pr delegates to shipyard pr (on PATH), forwarding argv.
Shipyard owns skill-sync, version-bump, PR creation, tracking state, and
cross-host validation; pulp pr exists so the old invocation, the /pr
slash command, and natural-language triggers in the ci skill all continue
to work. Users can explicitly opt out per checkout with
pulp config set pr.workflow github or manual, or for one command with
--workflow / PULP_PR_WORKFLOW.
Invariants:
shipyard is on PATH, pulp pr execs shipyard pr <args> and exits
with shipyard's status. Do NOT add pre/post-processing in cmd_pr.cpp —
shipyard is the single source of truth.shipyard is NOT on PATH, pulp pr prints an install hint pointing
at tools/install-shipyard.sh and exits non-zero. It must NOT silently
fall back to gh; that is how Shipyard tracking gaps happen. Update the
hint text in cmd_pr.cpp::print_install_shipyard_hint() when the install
path changes.pr.workflow=github is the explicit GitHub CLI path. It requires gh on
PATH, runs the native gate/bump/PR flow, and leaves Shipyard tracking
disabled by design.pr.workflow=manual prints the suggested commands and exits before
pushing or creating a PR.pulp status reports the effective PR workflow and selected tool health.--native keeps the legacy in-process orchestrator for forensic/debugging
use. Do not use it as the default path and do not document it as the
primary surface — Shipyard-managed shipyard pr is the primary surface.pulp pr (shim or --native) still refuses to run on main.Gotchas:
cmd_pr.cpp triggers the cli-maintenance skill-sync gate.
If you're modifying the shim, the install hint, or --native logic,
add a bullet here and you're covered. If the change is mechanical (e.g.
renaming a helper) and genuinely doesn't need skill documentation, add a
Skill-Update: skip skill=cli-maintenance reason="..." trailer on the
tip commit.skill_sync_check.py,
version_bump_check.py) by hand. shipyard pr calls them with the
right flags via the shim. Direct invocation skips the commit-trailer
parsing and the PR-body rendering.feat: in a commit subject
does not upgrade the SDK. The Version-Bump: <surface>=<level> trailer
is authoritative and surface-scoped.pulp version check --with-bump-check is the fast sanity check to
run before pulp pr if you want to see what the gate will say. Same
script, --mode=report.pulp upgrade --check-onlyThe sandbox E2E harness runs this command with
PULP_UPDATE_CHECK_DISABLED=1 and expects a non-silent, network-free result.
If the update cache is empty in that mode, print the installed CLI version
and an explicit disabled/not-queried latest-version line instead of probing
GitHub Releases. Otherwise PR sandbox lanes can fail spuriously when GitHub
release fetches are blocked or rate-limited.
pulp run --headless / --screenshot / --frames / --watch (#914)tools/cli/cmd_run.cpp plus the shared parser in
tools/cli/cmd_run_parse.cpp (parse_run_options / assemble_launch_args)
expose four CI-friendly flags on top of the basic launch path:
--headless — run the standalone offscreen (no window). Forwarded
as --headless and as the PULP_HEADLESS=1 env var so binaries
that read either source pick it up.--screenshot <path> — save a PNG to <path> after rendering.
Implies --headless. Forwarded as --screenshot <path> AND
PULP_SCREENSHOT=<path>. If --headless is set without an
explicit screenshot path, the CLI writes
build/<target>.png so simple invocations still produce an
artifact.--frames <n> — number of frames to render before capturing
(default 1). Forwarded as --frames <n> AND PULP_FRAMES=<n>.--watch — re-launch the binary on file changes via the existing
watch_loop plumbing. Composes with the headless flags so dev
loops can render PNGs on every save.The CLI parser is unit-tested in test/test_cli_run_options.cpp
(parse + forwarding contract) and end-to-end shell-out coverage lives
in test/test_cli_shellout.cpp ([issue-914] tag), which exercises
the discover-binary → launch-with-flags → PNG-on-disk path against the
fixture binary in test/fixtures/cli_run_fixture.cpp.
Gotchas:
--screenshot without a path argument exits 2 with a diagnostic.
Bad --frames (non-integer / <= 0) exits 2 too. Both are caught
before project resolution so they fail fast in --help-adjacent
contexts.pulp-screenshot flow) read env first.--watch is consumed by the CLI; it is NOT forwarded to the
launched binary. The launched binary just sees the headless
flags.pulp validate — plugin-format validatorsRuns clap-validator / pluginval / auval / optional AAX validator
on built plugins in build/{CLAP,VST3,AU,AAX}. Lives in
tools/cli/cmd_validate.cpp.
Modes:
--strict — CI enforced tier. Any "skipped because tool not
installed" upgrades to exit 1. Use in CI gates.--all — also run optional vstvalidator + full AAX validation.--json / --report <path> — emit structured report
(validation-report-v1.schema.json).--screenshot — capture plugin editor PNGs under
artifacts/screenshots/.Gotchas:
--strict gates CI on it. A
green run without all four validators is not the same as a run
where all four passed — the advisory makes that visible.note_missing helper de-duplicates by tool name.note_missing("<tool>", "<format>", "<hint>")
alongside the ++skipped_missing_tool bump in the skip branch.
Otherwise --strict won't know about it.failed > 0 first. --strict only
adds "OR any skipped-missing-tool" on top. Genuine validator
failures still fail without --strict.pulp validate runs the same discovery used by
pulp doctor --validators and aborts cleanly if any candidate has
a broken code signature (the "ripped from .app bundle" pattern,
where amfid SIGKILLs the binary at launch with exit 137 / zero
stderr). If you add a new validator to cmd_validate.cpp, also
add it to the priority list in tools/cli/validator_discovery.cpp
so the preflight covers it.pulp doctor --validators (#743)Discovers auval / pluginval / clap-validator across well-known
paths (system → cask app bundle → PATH → ~/.cargo/bin) and verifies
each candidate's code signature is intact. The pure-logic core lives
in tools/cli/validator_discovery.{hpp,cpp} (no cli_common link
deps — same isolation pattern as version_diag / projects_registry),
so unit tests in test/test_cli_validator_discovery.cpp can stub
path_exists / path_owner_uid / assess_signature without shelling
out to spctl.
Signature assessment runs codesign --verify first (authoritative
integrity check) and only treats specific verdicts as Broken:
Anything else — including unsigned cargo binaries and system CLI
tools that spctl rejects with "code is valid but does not seem to
be an app" — is treated as Healthy because amfid won't kill them at
launch. The naive spctl --assess check the issue spec proposed
catches the failure mode but ALSO false-positives on auval and
cargo-installed clap-validator; the layered check fixes that.
--fix removes broken user-owned copies (uid matches getuid()).
Broken root-owned copies print sudo rm <path> and never
auto-elevate — that's the safety boundary the spec is explicit about.
When adding a new validator: extend the kValidators array in
validator_discovery.cpp, add its priority paths to
validator_priority_paths(), and add a unit-test scenario covering
all four states (Healthy, user-owned Broken, root-owned Broken,
Missing).
pulp upgrade — self-updateLives in tools/cli/cmd_upgrade.cpp (moved out of cmd_misc.cpp in
#547 Slice 2 so the update-check surface can grow independently) and
calls pulp::cli::pulp_upgrade_url_for() from
tools/cli/upgrade_url.hpp. The URL/asset-name convention is pinned
by the release workflow (.github/workflows/release-cli.yml) and guarded
by test/test_cli_upgrade_url.cpp — both need to agree.
Convention (don't drift):
Asset = "pulp-<platform>-<arch>.<ext>" (NO version in the filename)
URL = ".../releases/download/v<version>/<asset>"
arch = "arm64" | "x64" (NOT "x86_64")
ext = "zip" for windows, "tar.gz" otherwise
Gotchas:
v<version>), not in the file name.
PR #377 / issue #352 was the bug where the filename contained the
version and every upgrade 404'd. test_cli_upgrade_url.cpp explicitly
fails if the version reappears in the filename.x64, not x86_64. The release workflow uploads under x64.pulp. Phase 8+
archives contain Rust pulp, pulp-cpp, and the runtime library.
Pre-cutover C++ CLIs still run cmd_upgrade.cpp during that hop, so
the helper in upgrade_install.hpp must copy pulp-cpp and other
top-level payload files next to the current binary before self-replace
(#1673).upgrade_url.hpp, update the regression test in the
same PR. Both live at HEAD; drift between them is the whole reason
the header exists.pulp version checkValidates consistency across the three version-bearing surfaces:
CMakeLists.txt project(... VERSION x.y.z ...) ↔ compile-time PULP_SDK_VERSION constant."version" in .claude-plugin/plugin.json (semver)."version" in .claude-plugin/marketplace.json (must match plugin.json).Gotcha: JSON files can have multiple "version" fields (e.g. metadata.version, plugins[0].version). The check anchors on the top-level field via a ^(?: )?"version": regex — don't introduce a JSON parser unless you genuinely need schema validation.
These commands live in tools/cli/cmd_host.cpp (scan + host share a
file). When changing scanner.scan() signatures, update cmd_host.cpp's
ScanOptions construction in lockstep — the cross-format loop builds an
options struct per iteration.
pulp scan --no-load (#812)Filesystem-only enumeration mode. The default pulp scan opens each
discovered bundle via dlopen to read entry-point metadata; one
malformed plugin throwing in static-init aborts the whole scan
(libc++abi: terminating). --no-load lists bundles by path/format
without dlopen — the safe escape hatch when a host plugin crashes
the scanner. pulp scan --help short-circuits before plugin
enumeration so users can discover the flag even when the underlying
scan path is broken.
pulp project bump ↔ undo parity (#244)C++ and Rust both serialize bump-undo-*.json records but with
slightly different field sets — Rust writes a transient notes:[...]
array that the C++ writer never emits. The C++ JSON parser was
desync-prone on unknown ARRAY/OBJECT values; hardened in commit
8f29b1fd to skip them cleanly. When extending the undo schema,
keep both writers + the C++ parser in lockstep, and add a fixture-
level test in test/test_cli_project_bump.cpp that round-trips both
directions.
pulp projects list --json (#244)The Rust binary always had a --json lane; the C++ port for parity
landed via 70e94dd7. pulp projects is the only projects
subcommand with --json today — add/remove are write-only and
report success via exit code.
pulp coverage diff — local diff-coverage gatepulp coverage diff (in tools/cli/cmd_coverage.cpp) is a thin
shell-out to tools/scripts/local_diff_cover.sh. The script is the
single implementation; the CLI subcommand, the
.claude/commands/coverage-diff.md slash command, and the pre-push
hook all delegate there. Threshold + filters live in
tools/scripts/coverage_config.json — that JSON is the single source
of truth; .github/workflows/coverage.yml reads it via jq so the
CI gate stays in lockstep. When changing the threshold, edit the JSON
once and don't touch any of the four invocation surfaces. Anti-drift
test: tools/scripts/test_local_diff_cover.py::WorkflowSourceOfTruthTests
fails if a future edit hardcodes --fail-under=NN back into the
workflow.
tools/cli/cmd_host.cpp calls PluginSlot::process() which now takes
a ParameterEventQueue. When adding new CLI hosting commands, include
pulp/host/parameter_event_queue.hpp and pass an empty queue if you
have no automation to deliver. See docs/reference/host-thread-rules.md
for the full contract.
pulp projects + registry wiring (#499 / #552 Slice 1b)pulp projects list/add/remove live in tools/cli/cmd_projects.cpp.
The JSON file at ~/.pulp/projects.json (or $PULP_HOME/projects.json)
is maintained by tools/cli/projects_registry.{hpp,cpp} and is
populated automatically from cmd_create.cpp on successful scaffold
via pulp::cli::projects_registry::add_project(...).
Design decision (2026-04-21, locked in): registry is authoritative.
pulp create writes to it; pulp projects add/remove is the user
surface; pulp doctor --versions --scan-parents walks CWD ancestors
as an opt-in diagnostic but never mutates the registry. No silent
disk scans.
Gotchas:
projects_registry is deliberately decoupled from cli_common.
Same rule as version_diag — the unit test (pulp-test-cli- projects-registry) links just projects_registry.cpp +
Catch2 so the test binary stays small. Don't reach into
cli_common.hpp from inside the registry module.add_project dedupes by canonical path, not by string. The
canonicalish helper resolves symlinks via
fs::weakly_canonical; /tmp/... and /private/tmp/... land at
the same canonical form on macOS. Registry round-trip tests must
canonicalise the TempDir.path up front or REQUIRE(x == y)
comparisons will break.(missing) line and a copy-paste pulp projects remove <path>
hint — never auto-removed. Only explicit pulp projects remove
(or a manual JSON edit) mutates the registry.--scan-parents. The
scan returns deepest-first. The caller (cmd_doctor) dedupes
against the active project but keeps both ancestors if both
contain a pulp_add_* macro. The user resolves the ambiguity —
we surface both rather than picking arbitrarily.--scan-parents reads CMakeLists.txt with a simple regex
(\bpulp_add_[A-Za-z0-9_]+\s*\(). Matches any pulp_add_* macro
the SDK introduces without requiring a new entry here.pulp project bump / pulp project undo (#499 / #564 Slice 7)pulp project bump and pulp project undo live in
tools/cli/cmd_project.cpp and delegate to the pure-logic core in
tools/cli/project_bump.{hpp,cpp}. Behavior summary:
pulp.toml without Pulp's core/
source tree), bump treats pulp.toml sdk_version as the SDK pin,
rewrites it together with the versioned find_package(Pulp X.Y.Z ...)
call, and leaves project(NAME VERSION ...) alone as the app/plugin
product version.CMakeLists.txt,
locates the first Pulp pin (FetchContent GIT_TAG,
pulp_add_project(VERSION ...), or project(NAME VERSION ...)),
rewrites it atomically, records an undo batch, and prints Slice 3
(#548) migration notes for the hop.--all iterates ~/.pulp/projects.json (Slice 1b #552).bump-undo-<timestamp>.json and reverts each bumped
entry's recorded edits. New undo files may contain multiple edits
across pulp.toml and CMakeLists.txt; legacy one-edit files are
still parsed.Gotchas:
project command, not projects. Registry commands
(pulp projects list/add/remove) are plural; per-project pin
management is singular. Both exist side-by-side in the dispatch
table — keep them separate in help text and docs.: with -. ISO-8601 stamps contain
colons which are illegal on Windows. undo_batch_path() maps
2026-04-21T14:30:00Z → bump-undo-2026-04-21T14-30-00Z.json.
Tests pin this; don't "fix" it on POSIX without updating Windows.refuse_dynamic_pin() returns true for anything that isn't
semver-after-optional-v. Status ends up as "skipped" with a
human-readable reason in failure_reason. Do not rewrite these.pulp project bump is for consumer projects. From the Pulp source
tree, use pulp version bump and the normal release/PR workflow.pulp.toml sdk_version.
If a standalone project has project(NAME VERSION ...), that is the
app/plugin version and must not be interpreted as the SDK version.
Keep pulp.toml and find_package(Pulp X.Y.Z ...) in lockstep.sdk_path is rewritten only when it points at a managed Pulp cache.
Custom paths are preserved and reported; pulp build verifies
whether they satisfy the requested sdk_version.--allow-cli-skew is the explicit escape hatch. By default,
target SDK versions newer than the installed CLI fail fast and tell
the user to run pulp upgrade first.--allow-redundant bypasses the origin/main guard. pulp project bump fetches origin main best-effort and refuses by default when
main already pins the target-or-newer SDK. Fetch failures fail open
so offline users aren't blocked by stale refs. In standalone
projects, still compare local pulp.toml / find_package(Pulp ...)
lockstep before firing the guard: if sdk_version already matches
the target but find_package is stale, the command should repair the
local drift instead of forcing --allow-redundant.sdk_path rewrites are only real edits when the path
changes. If a standalone project already targets the requested SDK
and sdk_path already points at that managed cache, the command must
return "already at target version" rather than staging a no-op
rewrite just because the path is managed.project_bump is decoupled from cli_common. Same rule as
projects_registry / update_mode — the unit test binaries link
just the module + Catch2. Don't reach into cli_common.hpp from
project_bump.cpp.--flag. The Catch2 runner
parses --foo as a CLI flag even inside a string argument, so
ctest's Catch2 invocation fails with "unrecognised token". Rename
bump-all test cases to "bump all ..." instead of "--all ...".update.bump_projects.
cmd_upgrade.cpp reads the key (default prompt) and either
prints the pulp project bump --all hint or stays quiet on
off. The actual exec-into-new-binary lands in a follow-up; this
slice just wires the nudge.git -C <proj> status --porcelain. If
git isn't on PATH, cmake_is_dirty() returns false — we refuse
to block on a missing tool. --force-dirty is the explicit
override for users who WANT to bump alongside other edits.from. That captures the widest set of applicable
notes when different projects were on different versions.pulp doctor --versions — version diagnostics (#499 Slice 1)The first slice of the release-discovery UX (issue #499) is a pure
diagnostic that short-circuits the doctor pipeline: it prints
CLI/SDK/Plugin versions side-by-side plus advisory skew warnings and
always exits 0. Lives in tools/cli/version_diag.{hpp,cpp} with
cmd_doctor as the only caller.
Gotchas:
version_diag is deliberately decoupled from cli_common. It
re-implements its own tiny read_toml_scalar / user_home_dir_local
helpers so the unit-test binary can link just version_diag.cpp —
no pulp::runtime link surface. If you add a new helper, keep it
local unless you've also evaluated the test impact.pulp doctor --versions as a routine health check in a pipeline.
This is a design choice, not an oversight.doctor --versions.
pulp build / pulp dev may reuse the same semver parsing for a
hard preflight while doctor --versions itself remains advisory.
Keep that split explicit: diagnostics stay 0-exit, execution paths
decide whether to block.M.N.P (e.g. 0.24.0-dev, a git SHA) has
Semver{.comparable = false}. Skew analysis short-circuits on
non-comparable inputs per the design doc..claude-plugin/plugin.json.
The installed-plugin layout inside ~/.claude/plugins/pulp/ or
~/.claude-plugin/pulp/ is an open question in the design doc —
the lookup is best-effort and deliberately forgiving; when in
doubt it reports "(not found)" instead of failing.cli_min_version is optional and additive. The field only
becomes meaningful from the first release that needs it; before
then it's silently absent and skew analysis skips. Don't
retroactively add it to every pulp.toml in the repo.min_cli_version mirrors the same semantics (Slice 6,
#551). A plugin plugin.json with no min_cli_version skips the
check silently; a newer min_cli_version emits the same advisory
WARN finding in analyze() and an inline "needs CLI: >= vX.Y.Z"
line under the Plugin entry in the human report. The JSON surface
adds a plugin_min_cli top-level field alongside cli and
plugin — do not rename it; tools/scripts/cli_version_check.sh
and the upgrade skill parse it by key.Slice 1b (#552) extended the diagnostic with --scan-parents and
--json plus the ~/.pulp/projects.json registry — see the section
above for registry gotchas. The --versions core remains pure-logic
and scoped to version_diag.{hpp,cpp}.
Follow-up slices tracked against #499: Slice 2 (update-check), Slice 3
(migration docs), Slice 4 (/upgrade skill), Slice 5 (mode
enforcement), Slice 6 (plugin ↔ CLI skew — min_cli_version +
plugin_min_cli JSON + tools/scripts/cli_version_check.sh). Do not
land them piecemeal under Slice 1's PR; file new issues and PRs.
pulp doctor --caches — FetchContent cache health (#744)Like --versions, --caches is a dedicated diagnostic that
short-circuits the doctor pipeline. It scans Pulp's shared FetchContent
source cache (the one pulp_register_fetchcontent_source populates) and
classifies each entry as healthy, dangling-symlink, stale-commit, or
root-owned. Lives in tools/cli/fetchcontent_cache.{hpp,cpp} with
cmd_doctor and the cache_preflight_check helper in
cli_common.cpp as the only callers.
Gotchas:
cli_common-decoupling rule as version_diag.
fetchcontent_cache.cpp reimplements user_home_dir_local and a
small env-string helper so the unit-test binary
(test/test_cli_fetchcontent_cache.cpp) can link only
fetchcontent_cache.cpp and Catch2 — no pulp::runtime, no
cli_common. If you add a helper, keep it local unless you check the
test impact first.DiscoveryEnv::lstat / stat_follow / list_dir callables
set up by make_real_env. Tests construct their own env with
deterministic mock callables — they never touch
~/Library/Caches/Pulp/. Adding new classification logic? Put the
syscalls behind the env so the new behaviour can be covered without
prepping a fixture cache on the dev's machine.<dep>-src/-build/-subbuild)
always coexist with the populated source dir we created. They are
CMake's own working state, not entries we own. Discovery treats any
trailing segment of src, build, or subbuild as scratch and
classifies them Healthy regardless of declared REF — clobbering them
defeats the configure-time cache. The
discover: FetchContent scratch dirs do not register as stale-commit
test pins this behaviour.--fix only touches user-owned entries. Root-owned classification
is the safety gate: an entry whose POSIX uid is not geteuid() gets
RootOwned even when the underlying state would otherwise be
Dangling or StaleCommit, and apply_fixes skips it. Agents must
never silently elevate; the user has to run sudo rm themselves.
On Windows, ownership is treated as user-writable (POSIX uid is a
no-op concept on the platform that hosts the cache under the user's
profile by definition).pulp_register_fetchcontent_source is called without a REF
argument (some optional deps are floated by branch), the entry is
classified Healthy regardless of directory contents. Adding a new
cache classification? Mirror this guard so missing-data inputs
don't fire false positives.CMakeLists.txt are stripped before the regex pass.
The parse_declared_refs_from_text helper does a line-by-line
first-# cut before scanning. A commented-out
pulp_register_fetchcontent_source(foo REF bar) does NOT pollute
the declared-refs map, which keeps stale-commit detection honest
for example/documentation snippets in CMakeLists.needs_configure. pulp build only runs
the preflight when CMake will reconfigure; incremental rebuilds
skip it (the cache scan is cheap but the gate matters more — a
healthy cache that fits a stale CMakeCache.txt would surface the
bad path eventually but not on the cheap incremental loop).
pulp test mirrors the rule: preflight only runs when a cold-start
build is needed (no CMakeCache.txt).PULP_SKIP_CACHE_PREFLIGHT=1 bypasses the preflight, NOT the
doctor command. Set it in CI environments that intentionally
curate the cache and would rather see CMake's native error than
the early gate. pulp doctor --caches always runs its scan
regardless — the diagnostic is the diagnostic.pulp doctor --caches --json prints
{"cache_root", "healthy", "entries": [{"name", "path", "status", "is_symlink", "resolved_target", "declared_ref", "cached_ref", "dep_name", "reason", "remediation", "fixable"}]}. status
values are healthy, dangling-symlink, stale-commit,
root-owned, unknown. Don't rename keys; scripts and the
rust-cli port (#740 family) parse them by name.Adjacent modules to coordinate with: tools/cmake/PulpFetchContent.cmake
(the cache-root and sanitize-suffix logic — default_cache_root and
sanitize_ref in fetchcontent_cache.cpp MUST mirror it), and the
build / test commands that call cache_preflight_check.
pulp config + update-check (#499 Slice 2 / #547)Slice 2 wires a 24h update-check cache plus a config surface for
~/.pulp/config.toml. Key layout:
tools/cli/update_check.{hpp,cpp} — pure-logic core. No
cli_common link dep so the unit tests in
test/test_cli_update_check.cpp can compile it standalone (same
pattern as version_diag). Exposes CacheEntry, parse_cache_json,
serialize_cache_json, read_cache_file, write_cache_file,
is_cache_stale, is_newer, compose_banner,
write_toml_key_in_section, and the Fetcher interface with a
real GitHubReleasesFetcher (curl/PowerShell) so tests can inject
a fake and never hit the network.tools/cli/cmd_upgrade.cpp — moved out of cmd_misc.cpp. Adds
--check-only reading the cache. Writes
banner_shown_for_version after a successful upgrade so the
next-invocation banner stays quiet for the version we just installed.tools/cli/cmd_config.cpp — pulp config get|set|list with an
allow-list of keys (update.mode, update.check_interval_hours,
update.channel). Allow-list prevents typos silently inflating the
config surface.tools/cli/pulp_cli.cpp — maybe_emit_update_banner_and_refresh()
runs before dispatch. PULP_UPDATE_CHECK_DISABLED env short-circuits
it (used by CI). banner_blocked_commands = config, version,
help so machine-parseable output stays clean.Pulp vX.Y.Z available (you have vA.B.C). Run \pulp upgrade` or `pulp config set update.mode manual` to silence.`
Emitted on stderr, not stdout — never corrupts piped output.Gotchas:
std::thread. Codex 2026-04-21
wave 2 P1 flagged the original std::async + static-future
pattern as blocking on destructor; the current
std::thread(...).detach() is correct. Do NOT regress this back
to std::async without understanding the Windows CRT finalization
path — the process must be able to exit while the fetch thread is
still in-flight..tmp + rename. A torn write just
forces a re-fetch on the next invocation, not corruption. Cross-device
rename falls back to copy_file + remove.git interpret-trailers --parse treats them as non-trailers.Slice 5 wires all four update.mode values into the dispatch path in
pulp_cli.cpp and adds the auto-mode staging + Windows tombstone
cleanup. Key layout:
tools/cli/update_mode.{hpp,cpp} — pure-logic core: Mode enum,
snooze read/write, pending-upgrade JSON round-trip, tombstone path
helpers, mode-specific banner composers, decision helpers
(decide_prompt_banner, should_stage_auto_download). No
cli_common link dep — same standalone-test pattern as
update_check. Unit tests in
test/test_cli_update_mode.cpp mock the filesystem via per-test
tmpdirs and inject time via explicit epoch-seconds arguments.tools/cli/pulp_cli.cpp — maybe_emit_update_banner_and_refresh
now consumes update_mode. Decision tree:
off → zero I/O, zero network, zero banner.prompt → one-shot banner per new version (Slice 2 behavior,
preserved); 24h snooze via ~/.pulp/update-snooze when it's set
(writes happen from cmd_config on mode-change and from the
/upgrade Claude skill on user decline — the dispatch path
never writes the snooze itself, it only reads it).manual → one-liner per new version ("Run pulp upgrade when
you're ready."), suppressed after banner_shown_for_version
matches.auto → writes ~/.pulp/pending-upgrade, prints "downloaded,
will complete on next invocation". The actual binary swap lives
in cmd_upgrade — Slice 5 does NOT download in the background
thread, only stages intent via the marker file. This preserves
Section G's "no binary is ever replaced without the user's
session touching pulp again".tools/cli/cmd_config.cpp — pulp config set update.mode ...
now clears ~/.pulp/update-snooze as a side effect. Reason: a mode
change is itself an act of re-engagement with update management,
so an existing 24h snooze would otherwise silence the new mode's
behavior. Also adds the update.bump_projects allow-list entry
(values: prompt | auto | off, default prompt) as a reserved
stub for Slice 7 (#564) — accept it now, implement in Slice 7.test_cli_update_mode.cpp):
Pulp vX.Y.Z available (you have vA.B.C). Run \pulp upgrade` when you're ready.`Pulp vX.Y.Z downloaded. The upgrade will complete on your next \pulp` invocation.`Pulp CLI upgraded to vX.Y.Z. Run \pulp upgrade --notes` to see what changed.`Gotchas specific to Slice 5:
*.pulp.old). The swap in
cmd_upgrade on Windows can't overwrite a file-locked running exe
in place. The rustup/pip/Python pattern is: MoveFileEx(exe, exe.pulp.old, REPLACE_EXISTING) (rename-out the old bytes),
then copy the new binary into the original path. On the NEXT
invocation of the new binary, cleanup_tombstone() deletes the
.pulp.old file. macOS/Linux overwrite the running inode fine —
cleanup_tombstone() is a no-op there. Always call cleanup from
the dispatch hook (top of main) so the sweep is universal.cmd_upgrade directly or start a download that can race
main()'s exit. The staging path here is intentionally a
marker-write + user-facing notice, not a network fetch.decide_prompt_banner is pure. Never call it from the snooze
write path — it'd double-count the banner against the
banner_shown_for_version counter. The snooze file is written by
(a) cmd_config on mode change (as a clear) and (b) the
/upgrade Claude skill on explicit decline. Nowhere else.update.bump_projects is an accept-only stub in Slice 5.
Don't wire behavior for it yet; Slice 7 (#564) owns that. If you're
tempted to check the value here, stop — it should round-trip
through pulp config get/set only.pulp upgrade --notes (#499 Slice 3 / #548)Slice 3 extends cmd_upgrade with an embedded, per-release migration
index. The table is generated at CMake configure time from
docs/migrations/*.md and compiled into the binary so upgrade notes
are always in lock-step with the shipped version — no runtime
download, no filesystem scan.
Key layout:
docs/migrations/vX.Y.Z.md — one file per release with notes
worth surfacing. TOML frontmatter fields: version (required),
breaking (bool), applies_if (expression string), summary.
docs/migrations/README.md is the schema reference; the codegen
skips it. Only write a note when a reasonable pro developer needs
to change code / config / habits — not every PR.tools/scripts/build_migration_index.py — standalone Python
codegen. Parses the TOML frontmatter, escapes the Markdown body for
a C++ string literal, sorts entries by parsed semver, and writes
tools/cli/generated/migration_index.cpp. Runs from
tools/cli/CMakeLists.txt via add_custom_command; also invokable
directly for iteration.tools/cli/migration_index.hpp — schema (MigrationEntry),
EvalContext, evaluate_applies_if, entries_for_hop,
applicable_entries, render_notes_text, render_notes_json.
Link-free from cli_common (same pattern as version_diag /
update_check).tools/cli/migration_runtime.cpp — evaluator + renderers. The
generated migration_index.cpp defines only the data table so
migration_runtime.cpp is the single TU unit tests build against
(the test provides its own stub kMigrationIndex).pulp upgrade --notes [--json] [--from X --to Y] — new flag.
No network, no binary swap. --json is stable-shape for Slice 4
(/upgrade Claude skill); do NOT rename the keys (from, to,
entries[].version|breaking|summary|applies_if|body) without
bumping the skill.applies_if grammar:
expr := or
or := and ("||" and)*
and := cmp ("&&" cmp)*
cmp := ident op version | "(" expr ")"
ident := "cli_version_from" | "cli_version_to"
op := "<" | "<=" | ">" | ">=" | "==" | "!="
Fail-closed semantics: unknown idents, malformed expressions, or unparseable context versions all evaluate to false (so a bad note doesn't noise the output). Empty expression matches every hop.
Gotchas:
applies_if have optional v prefix, but
the lexer requires v to be followed by a digit. vnull is
NOT a version; it's an identifier (and fails closed). If you need
a plain v-prefixed literal, make sure the next char is 0-9.from is exclusive, to is inclusive. entries_for_hop(A, B)
returns entries with version strictly > A and <= B. Stepping
--from 0.27.0 --to 0.27.0 returns zero entries. This is
intentional — a no-op hop prints "No migration notes apply."docs/migrations/README.md is excluded from the index. It's
the schema reference, not a migration note. Don't rename it without
updating build_migration_index.py..cpp lives under ${CMAKE_BINARY_DIR}/ — it is NOT
checked in. The source of truth is docs/migrations/*.md.version = "X.Y.Z" across two files is a hard error.
The codegen exits 2 and CMake fails to configure. Rename one.migration_runtime.cpp
explicitly #include <cstddef>, <sstream>, <string>, <vector>,
<tuple>. Don't rely on libc++ giving you those transitively.