一键导入
cli-maintenance
Checklist and decision tree for adding, modifying, or removing CLI commands. Keeps CLI source, slash commands, docs, and skills in sync.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Checklist and decision tree for adding, modifying, or removing CLI commands. Keeps CLI source, slash commands, docs, and skills in sync.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Pulp musical/media time primitives, immutable compiled tempo maps, monotonic transport beats, exact sample anchors, corrected inverse conversion, and shared transport quantization arithmetic.
Import designs from Figma, Stitch, v0, Pencil, React Native, or Claude Design into Pulp web-compat JS with automated visual validation. Claude Design imports also scaffold a pulp::view::EditorBridge handler file. Versioned parser, format, and compatibility-schema detection lives behind `--detect-only` and `--report-new-format`.
Pick the right Pulp Stream for a given I/O task, wire async callbacks correctly without deadlocking the worker, and avoid the backpressure / cancellation footguns in `pulp::runtime::AsyncStream`.
Editor lifecycle and multi-view attach for Pulp plugins — when to override Processor::create_view(), the open → notify_attached → resize → close protocol, release_view() ownership rules, and secondary-view roles.
Load, run, and test VST3 / AU / CLAP / LV2 plugins from Pulp code. Use when working on `core/host/` (scanner, plugin_slot, signal_graph), when adding a new format backend, when wiring a plugin into a SignalGraph, or when writing an integration test that needs a real plug-in binary.
Audio Unit v3 (AUAudioUnit) format adapter for Pulp — render-block wiring, parameter tree bridging, MIDI / sysex via AURenderEvent, sidechain pulls, state persistence, iOS extension surface, and the pitfalls discovered while wiring the adapter.
| 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 fileFor Rust-native commands, the source of truth is
experimental/pulp-rs/src/main.rs's enum Command plus the matching
experimental/pulp-rs/src/cmd/<name>.rs implementation. Add the command to
experimental/pulp-rs/src/help.rs too; the installed Rust binary's help banner
is user-facing even when there is no C++ table entry.
cli_common.hpp is split along the implementation seam — each header carries
the declarations for one sibling .cpp. Put a new helper's declaration in the
header that matches the .cpp you implement it in:
| Header | Implementation | Owns |
|---|---|---|
cli_common.hpp | cli_common.cpp | Command forward decls, repo/build-dir discovery, build + validator helpers, interactive prompts |
cli_sdk.hpp | cli_sdk.cpp | SDK resolution, pulp.toml / user-config reads and writes, PR-workflow selection, version banners |
cli_doctor.hpp | cli_doctor_helpers.cpp | DoctorCheck and the run_doctor_* probes |
cli_fs_util.hpp | cli_fs_util.cpp | Filesystem/archive-safety helpers, in namespace pulp::cli::fsutil |
Two things about this that are easy to get wrong:
cli_common.hpp includes
cli_doctor.hpp and cli_sdk.hpp, so every command file that includes
cli_common.hpp still sees the whole API and nothing fails when you park a
new SDK or doctor decl back in cli_common.hpp. It compiles, and
cli_common.hpp quietly regrows into the grab-bag the split undid. Check
which .cpp your definition lives in and declare it in that .cpp's header.cli_fs_util.hpp is deliberately NOT included by cli_common.hpp. It is
namespaced and included directly (kit_commands.cpp includes it without
cli_common.hpp at all), which keeps archive handling off the cli_common
dep chain. Don't "simplify" by adding it to the cli_common.hpp include
block.path_is_within exists twice, with different semanticsThere are two same-signature path_is_within functions, and they do not agree:
::path_is_within (cli_common.{hpp,cpp}, used by cmd_create.cpp) calls
fs::absolute() on both arguments first, so a relative path is resolved
against the process CWD.pulp::cli::fsutil::path_is_within (cli_fs_util.{hpp,cpp}, used by
kit_commands.cpp) is purely lexical — no fs::absolute. The caller owns
resolution and must pass both arguments in the same frame of reference; a
relative/absolute pair shares no prefix and always answers "not within".They look like an obvious duplicate to collapse. Collapsing them by pointing the
cli_common callers at the fsutil one silently drops the absolute-ising step
and changes the answer for any relative input — a containment check that quietly
starts answering a different question. If they are ever merged, the
fs::absolute() has to move out into the cmd_create.cpp call sites, not
disappear. The fsutil contract (including the lexical-normalization caveat
that a symlink inside the root still reads as "within") is pinned in the header
comments and covered by the [fs-safety] cases in
test/test_cli_kit_commands.cpp — update both sides when changing it.
docs/status/cli-commands.yaml with:
name, status (use status vocabulary: stable/usable/experimental), summaryargs with name, kind (positional/option/flag/passthrough), descriptionsubcommands if applicableQuote any summary/description containing : — the manifest must stay
valid YAML. A bare description: Manifest kind: source, ui-kit, or template
is a YAML syntax error (so is a "-opened scalar with no closing quote), and it
will not be caught by review: for years the file was checked only by grep
(tools/check-docs.sh) and by a C++ CLI that never reads it, so six such
scalars accumulated and yaml.safe_load failed on the file outright — while
every gate stayed green. tools/scripts/tools_registry_check.py now parses it
to resolve pulp ... invocations in docs/status/tools.yaml, so a broken
scalar fails that check. Verify with:
python3 -c "import yaml; yaml.safe_load(open('docs/status/cli-commands.yaml')); print('OK')"
Create 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, identity, macos, overflow, projects, project, tool, tweaks
project, tool, and tweaks intentionally stay slashless: project
is a per-project SDK pin helper, tool is registry/install plumbing
for optional developer tools and importer add-ons, and tweaks is a
local pulp-tweaks.json drift diagnostic that mirrors the inspector
drawer. identity is an audit/review helper whose lockfile diff should stay
explicit in the terminal and commit. Agents call these CLIs directly. Keep this classification in
sync with tools/scripts/cli_sync_check.py and
tools/scripts/cli_mcp_parity_baseline.json.
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, ci-host, upgrade, prototype-loop, motion, trace, audio-harness, audio-inspect, audio-compare
audio-harness is a workflow slash command (wraps the audio observability harness ctest targets + the audio-harness skill) — it is NOT a pulp CLI subcommand. Note the distinction from the pulp audio CLI: that command owns the model/bundle tooling (model/excerpt-find/read-bundle), the offline pulp audio validate <verb> harness CLI (summarize/doctor/compare/assert, tools/cli/cmd_audio_validate.cpp, over captured WAVs / audio-run/ bundles — no live plugin), AND pulp audio render (tools/cli/cmd_audio_render.cpp driver + cmd_audio_render_parse.cpp pure parser + the header-only cmd_audio_render_step.hpp block stepper), which DOES load a plugin: it renders an explicit --plugin <bundle> offline through pulp::host::PluginSlot and emits a WAV + the same metrics manifest the validate verbs read. Three render gotchas: --param <id>=<value> is the PLAIN parameter domain (native min..max, NOT normalized [0,1] — the PluginSlot::set_parameter arg name normalized_value is a misnomer; every loader treats it as plain); --param @frame is sample-accurate — the per-block queue the stepper builds is forwarded straight to PluginSlot::process (all four loaders apply param_events at the sample offset; LV2 block-rate by its control-port nature) and the driver does NOT also call set_parameter (that double-applies); and the stepper is a deliberate callback-driven parallel to OfflineRenderHost::render (PluginSlot has no ProcessContext, so it can't reuse the core renderer) guarded by a block-partition-invariance test. pulp audio intentionally has no slash command of its own; the /audio-harness command documents the validate and render verbs. Keep the live boundary in sync with the audio-harness skill: live Audio Inspector use is landed under /audio-inspect / pulp run --audio-inspector, and live capture-to-WAV is landed in two modes — pulp run --audio-capture-wav (earliest-window int16 dump — good for validate summarize/assert) and pulp run --audio-capture-rolling (last-N via RollingAudioCaptureBuffer — the steady-state window doctor/compare want — float by default, or int24 via --audio-capture-rolling-format int24). When adding a run capture flag, mirror slice A/A2's surfaces: cmd_run.hpp/cmd_run_parse.cpp/cmd_run.cpp, the standalone detail/standalone_audio_capture_* writer + standalone_environment.hpp env+predicate, docs/status/cli-commands.yaml (under run), docs/reference/cli.md#run, and both the audio-harness and this skill. When adding a validate or render flag, update the matching cmd_audio_*.cpp, docs/status/cli-commands.yaml (nested under audio), docs/reference/cli.md#audio, and both skills. WAV writing is write_wav_file(path, data, WavBitDepth) — Int16 (default overload), Int24, or Float32. pulp audio render is also exposed as the pulp_audio_render MCP tool (tools/mcp/mcp_tools.cpp handler + pulp_mcp.cpp tools_list/dispatch + test/test_mcp_server.cpp membership/required-arg coverage + the docs/guides/claude-code-plugin.md tool table); it takes a single param/midi token (the hand-rolled MCP JSON has no array extractor), returns the metrics JSON, and defaults --out to a temp WAV. When adding a render flag worth exposing, mirror it there too.
pulp ship swap-pack signs a hot-reload UX bundle (tools/cli/cmd_ship.cpp). It is the
only ship subcommand that does NOT require a configured build/ dir (it signs an
explicit --bundle, gated out of the build-dir check by sub != "swap-pack"). It
reuses the header-only reload building blocks — build_signable_manifest (walk +
hash + capability inference from the JS), swap_pack_signing_summary,
serialize_swap_pack_manifest, and key_store — so the CLI itself is thin assembly.
The Ed25519 signing key comes from a --sign-key <file> (created if absent) or, on
macOS, the login keychain under pulp.reload.signing.<plugin_id> / account gen1
(stored as a single-line base64 of the key blob); a freshly generated key prints a
loud provenance/backup banner and is NEVER silently regenerated (a corrupt keychain
entry is refused, not overwritten). The subcommand is GPU-gated only because the whole
CLI target is (if(PULP_ENABLE_GPU) around add_subdirectory(tools/cli)), so its
shell-out test pulp-test-cli-swap-pack runs in GPU builds.
pulp audio compare is the shipped CLI verb over the dev-only Python Audio Quality Lab
compare surface (tools/audio/quality-lab/, advisory measure→compare→judge). It is a thin
ORCHESTRATOR: tools/cli/cmd_audio_compare.cpp locates the opt-in managed tool
($HOME/.pulp/tools/python-envs/audio-quality-lab/run.sh, via tool_registry.hpp
locate_tool) and forwards the compare verb + all flags to it — no numpy/soundfile/FFT ever
links into the MIT CLI. It parses arity locally (missing WAVs → exit 2), prints an actionable
pulp tool install audio-quality-lab hint + exit 1 when the tool is absent, and otherwise
passes the tool's stdout/stderr + exit code straight through (2 == could-not-measure/invalid,
never a judgment). When touching it, mirror: cmd_audio_compare.cpp/.hpp, the dispatch +
usage line in cmd_audio.cpp, CMakeLists.txt, docs/status/cli-commands.yaml (nested under
audio), docs/reference/cli.md#audio, the shell-out test test/test_cli_audio_compare.cpp
(help/arity/not-installed-hint/forward-and-passthrough via a fake run.sh), and the
audio-harness skill. The /audio-compare slash command wraps the SAME surface for agents, and the
pulp_audio_compare MCP tool mirrors this shipped verb (like pulp_audio_render mirrors
pulp audio render): handler handle_audio_compare in tools/mcp/mcp_tools.cpp (+ decl in
mcp_tools.hpp), tool JSON + dispatch + using in pulp_mcp.cpp, an mcp_only entry in
tools/scripts/cli_mcp_parity_baseline.json, and membership + required-arg coverage in
test/test_mcp_server.cpp + the docs/guides/claude-code-plugin.md tool table. A new audio
SUB-verb needs no CLI↔MCP parity promotion (the checker only tracks top-level pulp
commands), but a new MCP sub-tool still needs its baseline mcp_only entry. Distinct from the
gate-oriented pulp audio validate compare (null/spectral diff, nonzero exit): pulp audio compare is an advisory judgment, never a gate.
Inspector-proxy MCP tools use a different, lighter pattern than the
mcp_tools.cpp-handler tools above. pulp_motion_* and pulp_trace_* do NOT
have mcp_tools.cpp handlers — they inline-forward in pulp_mcp.cpp's
dispatch (an else if (name == "pulp_X_*" || …) block that maps each tool to an
inspector_method + --params '<args_json>' and shells pulp inspect --command).
So a new inspector-proxy tool needs only: tool JSON in tools_list_json(), the
inline dispatch block, membership in test/test_mcp_server.cpp's expected list,
and the docs/guides/claude-code-plugin.md table — no handler, no mcp_tools.*.
Parity: a tool whose top-level CLI command already carries a cli_only baseline
entry (e.g. trace) needs NO new baseline row; only add an mcp_only entry for
a tool with no CLI peer at all. Client-side CLI verbs with no inspector RPC
(trace doctor / open / fetch, offline query --trace) get no MCP tool —
say so in the plugin-table row and the cli_only reason so the asymmetry reads
as intentional.
Not every slash command wraps a pulp CLI subcommand. A slash command may
also document a developer-tool surface with no CLI backing — e.g.
audio-inspect opens the in-app Audio Inspector window
(pulp::view::AudioInspectorWindow, registered via CommandRegistry), so
it needs no cli-commands.yaml entry and no pulp <name> subcommand. When a
slash command is window/feature documentation rather than a CLI wrapper, skip
the CLI-source / manifest / docs/reference/cli.md steps and just keep the
.md and this cross-reference accurate.
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
python3 tools/scripts/check_cli_mcp_parity.py --mode=report
cli_sync_check.py unions the C++ command tables with Rust-native commands
from experimental/pulp-rs/src/main.rs. check_cli_mcp_parity.py uses the
same installed-command model by default, so a Rust-only command still needs
either a matching pulp_<command> MCP tool or an explicit
tools/scripts/cli_mcp_parity_baseline.json reason.
Every top-level CLI command is checked for MCP parity by
tools/scripts/check_cli_mcp_parity.py. The check enforces an
invariant: a new CLI command must either land alongside a
pulp_<command> tool in tools/mcp/pulp_mcp.cpp (with both a
tools_list_json() entry AND a handle_request() dispatch arm),
OR it must be added to tools/scripts/cli_mcp_parity_baseline.json
under cli_only with a one-line reason.
Decision heuristics for "does this deserve MCP exposure":
pulp kit and
pulp content documented as top-level CLI commands, but expose agent-safe
sub-tools such as pulp_kit_plan, pulp_kit_apply,
pulp_content_preview, and pulp_content_install so MCP callers cannot blur
inspect/preview/approve/apply boundaries. Add those sub-tools to the
mcp_only baseline with a reason that they are sub-tools of the umbrella CLI
command, not missing top-level CLI peers.tools/cli/kit_commands.cpp is a frozen refactor hotspot. Before moving kit
command code, follow tools/cli/KIT_COMMANDS_MODULE_MAP.md: keep manifest
validation, archive safety, publish policy, apply/remove mutation, profile
verification, and dispatch in separate modules, and lower the hotspot ceiling
when the split shrinks the monolith.The gate runs in three places, all pinned to the same script:
hooks/scripts/cli-plugin-sync.sh — advisory --mode=hint after Edit/Write of
pulp_cli.cpp, pulp_mcp.cpp, or the baseline JSON..github/workflows/version-skill-check.yml — enforcing --mode=report
on every PR. Hard-fails if a new CLI command lacks both an MCP tool and
a baseline entry.Naming convention: hyphenated CLI command import-design ↔ underscored
MCP tool name pulp_import_design. The script handles the conversion
automatically; baselines should use the form natural to each side
(cli_only is hyphenated, mcp_only carries the full pulp_ prefix).
When promoting an entry off the cli_only list, add the matching
pulp_<command> tool to tools/mcp/pulp_mcp.cpp and the parity check
will auto-detect the new coverage; remove the baseline entry in the same PR.
pulp_inspect_set_param)Inspector tools are MCP sub-tools of pulp inspect, so they live in the
mcp_only baseline (no top-level CLI command of their own) — add the new
pulp_inspect_* name there or the parity check hard-fails. Two gotchas when
the tool takes arguments:
pulp inspect --params '<json>', not by concatenating
the JSON after --command METHOD. The CLI parses --command and --params
as separate flags; a bare {...} token is ignored. Read-only tools that take
no args sidestep this, so don't copy their dispatch shape for a tool that
carries a payload.Runtime.evaluate,
Runtime.getCapabilities, Runtime.interrupt, and Console.getMessages
(device-log cursor poll) reach the live JS engine when a host calls
DomainHandler::set_script_inspector(session.script_inspector()). Evaluate is
marshaled onto the engine thread by ScriptInspectorBridge — single in-flight,
~2 s timeout, auto-interrupt on hang. It is an honest evaluate/inspect console,
NOT a step debugger: mainline QuickJS has no breakpoint protocol, so
getCapabilities reports canBreak/canStep/canInspectLocals=false. Cover these
in test_inspector_domains.cpp. See docs/reference/scripted-ui-inspector.md
and the engine skill's interrupt section.State.setParameter) with validation + gesture wrapping in
StateInspector/DomainHandler — never via Runtime.evaluate. Cover the
happy path, the unknown-id error, and any normalized/raw mode in
test_inspector_domains.cpp.Same as above, focus on steps 2, 4, 5, 6, 7. Key risks:
cli-commands.yaml.mdpulp <cmd> --help
and docs/status/cli-commands.yaml. Do not invent convenience aliases such
as --formats; for platform-gated defaults, document the real opt-in flag
(for pulp create, --targets android) or omit the prompt entirely./test must invoke pulp test or ./build/pulp test, not raw
ctest --test-dir build; the CLI owns project-root resolution, cold-start
builds, FetchContent cache preflight, and ctest passthrough. If slash-command
arguments already contain ctest flags such as --exclude-regex, forward them
after pulp test instead of wrapping the whole argument string in -R.std::filesystem::path::parent_path() before creating directories and
add shellout coverage for the bare-filename case.pulp status — build-governance tier linepulp status reports the active host-resource governance tier via a
Build governance: Tier N (…) line, backed by detect_build_governance() in
tools/cli/tartci_lease.cpp. Detection is fail-safe (never throws): Tier 2 when
TARTCI_ORCHARD_URL is set, else Tier 1 when a resolvable tartci
(PULP_TARTCI_BIN or PATH, unless PULP_TARTCI_LEASES=0) answers
tartci host-profile (surfacing its PULP_BUILD_JOBS / PULP_BUILD_MEM_BUDGET_MB),
else Tier 0 (the CLI's built-in bounded builds). Coverage lives in
test/test_cli_tartci_lease.cpp. Keep the tier semantics in step with the
lease-acquisition path in the same file.
pulp add can now generate CMake for source-backed FetchContent packages, not
only header-only packages or upstream-exported targets. The registry's
cmake.sources field means "compile these fetched source files into the
declared target." Keep that behavior centralized in
package_commands_util.cpp so guarded and unguarded blocks stay identical
except for their surrounding if(...) condition.
When adding a new generated-target package shape, update all four surfaces in
one PR: tools/packages/registry-schema.json, package_registry.{hpp,cpp},
the CMake block generation helpers, and test/test_cli_package_commands.cpp.
mts-esp is the reference source-backed case: generated static target,
position-independent code, include dir rooted at the fetched source, and
${CMAKE_DL_LIBS} linked when available.
Some header-only packages should be fetched source-only because their upstream
CMakeLists.txt builds tools/tests or exports a target shape Pulp does not want
to impose on plugin projects. Set cmake.add_subdirectory=false; generation
must use FetchContent_MakeAvailable() with an inert SOURCE_SUBDIR, then
create the declared interface target itself. sst-tuning-library is the
reference case.
pulp dev --hot-dsp — live DSP hot-swap dev looppulp dev --hot-dsp (cmd_dev.cpp) is a watch-loop MODE flag, not a new command.
It sets WatchOptions::hot_dsp, which watch_loop() (cli_common.cpp) reads to
suppress the post-rebuild relaunch: the launched app stays alive and its
ReloadableShell filesystem watcher hot-swaps the rebuilt DSP logic library in
place (relaunching would kill the plugin + lose audio/UI state). Gotchas:
--hot-dsp requires --run <target>; cmd_dev errors (exit 2) otherwise.ReloadableShell); for a plain plugin it just means "don't relaunch." The CLI
deliberately doesn't inspect the target — it only changes relaunch behavior.cmake --build of the project rebuilds it, and the shell's poll picks it up.pulp loop --ar-swap-from is retired to a redirect that points here — it
no longer prints a bare not-implemented notice (cmd_loop.cpp). If you touch the
reload dev loop, keep that redirect message accurate.pulp-cli) is GPU-gated (PULP_ENABLE_GPU), so a GPU-off
worktree can't build/run it; verify CLI edits via a -fsyntax-only parse and
rely on the GPU-on CI lane for the link + shell-out --help assertion.pulp ship release where macOS-only
execution follows cross-platform flag parsing.pulp docs build-site, add a fake-tool PATH test that records
argv from a nested cwd and includes spaces in forwarded paths, so config
discovery and argument preservation do not depend on the real external tool
being installed.#if. E.g. pulp ship package --format appimage --binary <exe> [--icon <png>] parses everywhere, yet only the #if defined(__linux__)
arm routes to pulp::ship::create_appimage; on other platforms the flags are
accepted-but-inert. Document the flag in cli-commands.yaml regardless, and
put the behavioral assertion in a platform-gated unit test (here:
test_linux_packaging.cpp) rather than a cross-platform shellout, since the
required macOS lane can't exercise the Linux-only branch.pulp ship package (macOS) DEFAULTS to a single component-selectable .pkg:
it collects every built format into one create_combined_pkg call so the
installer shows a Customize pane (one pre-checked, toggleable choice per
format) — users are never forced to install every format. --separate is the
legacy per-format-.pkg path; --dmg makes disk images. Discovery scans the
whole build/{VST3,CLAP,AU,Standalone} tree, so a multi-example build bundles
EVERY plugin's formats (dozens of choices) unless you pass --product <name>
to scope to one product's bundles (also names the installer). Verify a real
installer by unpacking its Distribution (xar -xf … Distribution) and
checking for <options customize="allow"> + one <line choice> per format —
a flat Bom/Payload/PackageInfo archive is a bare pkgbuild with NO Customize.pulp ship doctor,
which makes signing non-interactive) has to be dispatched above
cmd_ship's build/CMakeCache.txt guard — that early-return fires first and
would otherwise reject it with "Build directory not found." Put such handlers
right after find_project_root().pulp ship doctor shells out to tools/scripts/ensure_signing_ready.sh (the
canonical logic + its own test_ensure_signing_ready.sh); the C++ side is a
thin pass-through, and ship sign invokes it as a best-effort quiet
preflight (|| true) so a doctor failure never masks the real sign error.
Keep secrets in ~/.config/pulp/secrets/, never the repo.After 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.
pulp import — framework-importer substratepulp import (tools/cli/cmd_import.cpp + import_run.{hpp,cpp} +
import_detect.{hpp,cpp} + import_spi.{hpp,cpp} + import_emit.{hpp,cpp} +
import_emit_scan.{hpp,cpp}) reads an existing audio-plugin project read-only
and emits a Pulp migration scaffold. The SDK owns only the generalized
substrate; the framework-specific parsers are vendor-specific add-on tools
in their own private repos, driven over a JSON-over-stdio SPI.
Gotchas / invariants when touching this surface:
cmd_import.cpp is arg-parse + dispatch only. The SPI-verb orchestration
(run_detect / run_inspect / run_emit and their shared helpers —
framework-index + importer resolution, the SPI request/response envelope
run_verb, the analyze/emit payload builders, the clean-room output gate,
and scaffold materialisation) lives in import_run.{hpp,cpp} under namespace
pulp::cli::import_run. cmd_import.cpp only parses flags into
import_run::ImportOptions and calls the three run_* entry points. Keep new
verb logic in import_run.cpp; keep cmd_import.cpp small. Both files (and
any new tools/cli/*import* file) must stay vendor-free — the
pulp-test-cli-import directory scan asserts no juce/iplug/steinberg/
wdl token appears in any of them.
Vendor-agnostic is enforced. SDK code, mainline tests, and generic CI
name NO vendor or framework. The ONLY place real markers (.jucer,
juce_add_plugin, iPlug PLUG_NAME, Steinberg::Vst::, …) may appear is
the DATA file tools/import/known-frameworks.json. Tests use a NEUTRAL id
(example-framework) and a temp index. test_cli_import.cpp has a guard
that greps tools/cli/*import* for juce/iplug/steinberg/wdl — keep new
import code clean of those tokens (put markers in the data file).
Detection markers are DATA, not code. import_detect.cpp only knows
the shape of a marker (file_glob / content_match + weight), never a
specific marker. Add a framework by editing the JSON index, not the engine.
The Rust front routes import via fallthrough automatically. import
is NOT a declared clap subcommand in experimental/pulp-rs/src/main.rs, so
it hits ErrorKind::InvalidSubcommand and clap_exit_code delegates to
pulp-cpp. You still add an Entry to help.rs::COMMANDS so the usage
banner lists it (and the C++ commands[] table in pulp_cli.cpp).
SPI version is negotiated every call. import_spi::check_version
compares the importer's response spi_version against the registry's
[spi_min, spi_max] window and fails loudly ("upgrade Pulp" vs "upgrade
the importer"). Never silently proceed on a mismatch.
The SPI request goes in on real stdin. import_spi::run writes the
one-line request to a temp file and redirects it into the importer through
the shell (/bin/sh -c '<cmd> < tmp' / cmd /c), because
ChildProcess::run captures stdout but doesn't feed stdin. Reads the first
non-empty stdout line as the response envelope.
Importer fields on ToolDescriptor are optional. frameworks,
spi_min/spi_max, sdk_min/sdk_max, capabilities, health_check
are parsed only when present. Don't add a fake vendor entry to
tool-registry.json; the loader tolerates their absence.
emit materialises a real scaffold; the SDK writes + gates the output.
detect/inspect/emit are all real. emit runs analyze → ProjectIR
then the SPI emit verb → an EmissionManifest (the importer PROPOSES
files, never writes them). The SDK then: parses the manifest
(import_emit::parse_manifest), runs the clean-room output denylist scan
(import_emit_scan::scan_manifest) over every generated/stub file,
computes a write-plan that rejects any path escaping --output
(compute_write_plan), writes each file (inline content, or a verbatim
copy_from copy for copied-user-file provenance), and writes
migration_status.json + .pulp-import-provenance.json. Parse / write-plan /
scan are pure functions over structs so they unit-test without spawning;
the spawn/IO is a thin shell in cmd_import.cpp.
The output scan is data-driven, not hardcoded. Keep the clean-room
denylist vendor-free: denylist_from_known_frameworks() builds it from the
known-frameworks index's content_match markers (the ONE place real tells
live). Do NOT hardcode juce/iplug/… tokens in import_emit_scan.cpp — the
vendor guard greps for them. copied-user-file provenance is EXEMPT from the
scan (it's the user's own DSP); only generated/stub content is scanned.
Watch comment wording too: a literal .jucer in a comment trips the juce
substring guard.
The importer may double-wrap the IR. When emit hands the analyze result
back as project_ir, an importer that frames analyze as {"project_ir": IR}
must unwrap its own envelope (the SDK passes the analyze result verbatim).
If a scaffold comes out with empty formats / pass-through-only DSP, suspect a
double-wrapped IR on the importer side, not the SDK.
inspect/emit are gated by the IMPORTER_TERMS accept-to-run gate
(import_terms.{hpp,cpp}, run_gate). The terms BODY is vendor DATA carried
on the add-on's ToolDescriptor (terms_text/terms_version/vendor_id) —
the SDK ships no terms body and names no vendor, it only surfaces + hashes the
text and records acceptance under ~/.pulp/importer-terms-accepted.json
(honours $PULP_HOME), keyed by importer id + an FNV hash of the terms.
A changed body → new hash → re-prompt. --accept-importer-terms is the
non-interactive (CI) path; without a TTY and without the flag the gate returns
NonInteractive and BLOCKS (exit 1) rather than hanging. Mirrors
pulp add --accept-license in UX + storage shape. --importer-cmd has no
registry entry, so --importer-terms-text/--importer-terms-version supply
the body directly (tests + power users). has_terms()==false (no body) →
the gate passes through transparently. run_gate takes injected GateIo
(in/out/interactive) + a now_utc string so it unit-tests deterministically
without a real TTY or clock.
Provenance PR-check is tools/scripts/check_import_provenance.py (neutral,
vendor-free), the audit that a migrated project landing in a PR was produced
clean-room: marker present + well-formed, valid per-file provenance values,
and no framework-source marker in any file the marker labels generated/stub
(copied-user-file is exempt). The content denylist is DATA from the
known-frameworks index ($PULP_KNOWN_FRAMEWORKS or tools/import/); with no
index the structural checks still run and the scan reports as skipped. Wired
into gates.sh as an opt-in lane (PULP_IMPORT_PROVENANCE_DIRS) so it's a
no-op for normal Pulp-repo pushes and only fires on a PR that lands a scaffold.
pulp import install <url> / uninstall <id> — URL-driven importer installtools/cli/importer_git_install.{hpp,cpp} (namespace pulp::cli::import_install)
is the install path an importer add-on actually reaches in practice: cloned from
a git URL, not resolved from the shipped registry. cmd_import.cpp dispatches
install/uninstall as the first token (a different arg shape from the
detect/inspect/emit verbs). This is DISTINCT from pulp tool install <importer>
below (registry entry + per-platform sha256-pinned artifact) — same install tree
(~/.pulp/tools/<id>/) and record dir (~/.pulp/importers/), different source.
Gotchas / invariants when touching this surface:
install_from_git runs git clone --depth 1 <url> with the user's
own git (so a private repo works iff they can clone it), then reads tool.json
(parse_tool_manifest: id, category=="importer", spi_min/max,
terms_version, terms_file, pinned_version) and the terms body from the
repo's terms_file. There is no shipped tool.json, no artifact, no sha256.error is a single fixed URL-agnostic string ("could not fetch
importer from the provided URL"); git's own words go in a SEPARATE git_output
field (surfaced verbatim so a human sees the real reason, but it is git's claim,
not the SDK's). The SDK must NEVER state/infer/record whether a repo exists or
is public/private — a user with access and one without get identical
SDK-authored text. test_cli_import_install.cpp asserts two different
unreachable URLs produce byte-identical error and that it contains none of
private/public/exists/not found/permission. Keep the Fetch stage
URL-agnostic; only the LATER stages (which run after a successful clone) may
reference repo contents.tools::check_importer_compat (the SDK speaks the degenerate
[kSpiVersion, kSpiVersion] window); the accept-to-run gate is
import_terms::run_gate against acceptance_store_path(). --accept-importer-terms
is the CI path; --force reinstalls over an up-to-date record. Uninstall reuses
tools::uninstall_importer(id). The clone is staged under tools_dir()/.staging
(same filesystem → atomic rename into place) and removed on any pre-placement
failure, so a declined terms gate or bad manifest leaves nothing behind.tool.json +
TERMS.md), so the git path itself is exercised with no network. Terms IO is
injected via GateIo. Keep this file (and the test) vendor-free — it is under
the pulp-test-cli-import* neutrality scan.pulp tool install <importer> — importer add-on packagingThe install-side contract for framework-importer add-ons lives in
tools/cli/importer_install.{cpp} (declarations in tool_registry.hpp).
pulp tool install <importer> and the pulp add <importer> alias both route
through it. User-facing contract: docs/reference/framework-importer-packaging.md.
Gotchas / invariants when touching this surface:
category: "importer". The
generic binary/python install path is untouched: cmd_tool's install/
uninstall first call handle_importer_install / handle_importer_uninstall,
which return std::nullopt for non-importers so the generic path still runs.
try_add_importer_alias is the pulp add entry — it only fires when the id
resolves to an importer in tools/packages/tool-registry.json.check_importer_compat requires the running SDK in
[sdk_min, sdk_max] AND the importer's [spi_min, spi_max] to overlap the
SDK's import-SPI window; (2) sha256 — the fetched/--from archive must match
the registry sha256; (3) skill + record. Keep the messages actionable
(upgrade Pulp vs upgrade the importer, refusing to install).importer_install.cpp, on purpose. It avoids
linking mbedTLS into the lightweight pulp-test-cli-* targets (which only link
pulp::platform). It's validated against FIPS-180-4 known vectors in
test_cli_importer_install.cpp — if you touch the digest, those vectors are
the guard. Do NOT swap it for pulp::runtime::sha256_hex without also adding
the runtime link to every test target that compiles importer_install.cpp.host_sdk_version()
reads PULP_SDK_VERSION (tests set it to drive the window check) and falls
back to PULP_SDK_VERSION_GENERATED from <pulp_version_gen.h>, included via
#if __has_include so unit-test targets (no generated header) still compile.
The pure functions (install_importer, check_importer_compat) take the SDK
version + SPI bounds as PARAMETERS — keep them parameterized so they stay
testable without globals.~/.agents/skills/<skill_name>/ honoring $PULP_HOME.
skills_dir() maps $PULP_HOME → $PULP_HOME/agents/skills (tests rely on
this); without it, the real ~/.agents/skills. Records go under
pulp_home()/importers/<id>.json. Uninstall recovers the skill dir name from
the record's skill_path so it removes the right directory even if the
registry entry changed.--from <path|file://> is importer-only. Both pulp tool install and
pulp add reject --from for non-importers. It's the offline/test source —
the checksum + version gates still apply, so a mock local package with a known
sha is the unit-test vehicle (build one with tar -czf, hash it with
sha256_file_hex, feed it back into the descriptor).docs/reference/framework-importer-packaging.md.
The CLI consumes the contract; don't bake a hosting URL, an LLVM pin, or a
signing identity into the SDK.tool_registry.cpp. tool_registry.cpp now
references importer_install.cpp + import_spi.cpp symbols, so BOTH
pulp-test-cli-tool-registry and pulp-test-cli-importer-install link all
three TUs. Adding a symbol used by cmd_tool means updating both targets.pulp tool install — bare binaries vs archives, and the verified-fetcher laneThe generic binary_download path in install_binary_tool (tool_registry.cpp)
assumes an archive: with no archive_format it defaults the download
extension to .tar.xz and runs extract_archive. A bare binary source (no
archive_format, e.g. Perfetto's trace_processor_shell) would fail there
("cannot extract" on a raw Mach-O/ELF) — and this path never verifies a SHA
(the sha256 field is read but unused). So a bare-binary tool is installed
only by its own SHA-256-verified fetcher, never this generic path:
install_binary_tool skips any source with an empty archive_format
(returns ok, installs nothing) so an --all sweep reaching it via pulp-cpp
doesn't error. The Rust front-end owns the real install: cmd/tool.rs
short-circuits install/update <id> to the verified fetcher, and its
install --all pre-fetches the bare-binary tool so the delegated C++ sweep
finds it already-present (C++ locate_tool searches tools/<id>/
recursively) and skips it.experimental/pulp-rs/src/cmd/trace_fetch.rs; pulp trace fetch and
pulp tool install trace-processor share that code. If you add another bare
binary, give it a verified fetcher — don't rely on the generic path.pulp tool info — Rust/C++ paritypulp-cpp tool info <tool-id> [--json] prints the same descriptor metadata
that installer add-on workflows need when deciding whether a registry entry is
a machine-scoped tool add-on, importer package, or legacy validator. The Rust
front-end owns pulp tool dispatch for installed users, so adding descriptor
fields or subcommands to tool_registry.cpp must be mirrored in
experimental/pulp-rs/src/tool_registry.rs and
experimental/pulp-rs/src/cmd/tool.rs; otherwise Rust pulp tool <subcommand>
can reject a command that the C++ delegate and tests already support.
Keep managed-install layout parity here too: C++ npm-package tools install
under $PULP_HOME/tools/npm-packages/<id>/run.{sh,bat}, so Rust
locate_tool and uninstall_tool must check/remove that wrapper directory
alongside binary-download and python-env installs. Otherwise Rust-native
pulp tool info, path, run, doctor, and uninstall can disagree with
tools installed by the delegated C++ install path.
The targeted pulp tool doctor <id> [--run] surface is part of that same
contract: --run executes the resolved tool path with no forwarded arguments
and returns its exit code. For npm_package tools, that path is the
run.{sh,bat} wrapper smoke check.
managed_by_pulp tool must be user-updatable + overridableConvention (hard rule for the opt-in tool lane, NOT for shipped-by-default
deps like Skia/Dawn): when you register a managed_by_pulp tool in
tools/packages/tool-registry.json, users must be able to update it and pin
their own version without waiting for Pulp to bump the committed pin. This
is what pulp tool update + the version-override tiers provide, and it is
enforced — do not add a managed tool that lacks it.
pinned_version. It is the anchor the update/override
path keys off. tools/packages/validate_registry.py's
validate_tool_registry fails the build if any managed_by_pulp tool ships
without one (covered by tools/packages/test_package_validation_tools.py).pulp tool update <id> [--version <v>] lives in
experimental/pulp-rs/src/cmd/tool.rs. Bare update re-installs at the
registry pin and clears any prior user override; --version <v> re-installs
at an explicit version and records a durable override. The archive re-fetch
delegates to pulp-cpp tool install <id> --force (Rust can't extract
archives), forwarding the resolved version via env — the override file is the
cross-language source of truth.experimental/pulp-rs/src/tool_version.rs):
PULP_TOOL_<ID>_VERSION env var → $PULP_HOME/tool-overrides.json (durable;
what --version writes) → registry pinned_version. pulp tool info
surfaces the active version and its source (text + --json as
active_version / active_version_source), so it is always explicit which
version is in effect and why.pinned_version), and — if you extend the update/override surface itself —
tool.rs + tool_version.rs + their tests, docs/status/cli-commands.yaml
(tool subcommands), docs/reference/cli.md#tool, and
docs/reference/extending-pulp.md. The full convention lives in
extending-pulp.md.pulp tool install <in-tree python tool> — source_dir installA Python tool that lives inside this repo (not on PyPI) registers as a
normal python_pip entry but adds two descriptor fields:
source_dir — repo-relative package dir (with a pyproject.toml), e.g.
tools/audio/quality-lab. When set, install_python_tool pip-installs that
directory (resolved via repo_root_from_registry(find_tool_registry_path()))
instead of <pip_package>==<version>. This mirrors the repo-local
npm_package precedent.module — the python -m <module> the run wrapper invokes (e.g.
quality_lab.cli); defaults to pip_package for classic PyPI tools.The managed venv still lands under $PULP_HOME/tools/python-envs/<id>/, so
locate_tool / uninstall_tool need no change. The tool's pyproject.toml
owns its dependency list (mirror requirements.txt). Per the parity rule
above, both new fields are also declared in experimental/pulp-rs/src/tool_registry.rs
(serde #[serde(default)], ignored on the delegated install path) so pulp tool info/list round-trip them. First user: audio-quality-lab.
Every extend-surface removal (pulp tool uninstall, pulp kit remove, pulp content remove, pulp add --remove) closes on an OK line that names what it
removed, not just the id — this is the shared extend-surface lifecycle
contract (see docs/reference/extending-pulp.md). tool uninstall prints
(removed <path>) from the returned PathBuf; content remove mirrors it with
the deleted content-pack path; kit remove deletes a set of lock-recorded
files, so it names the count instead ((removed N files)). When you add or
change a removal command, keep the OK line honest — echo the concrete path (or
count, for multi-file removals) the command actually deleted, and assert it in
the command's test via capture_stdout_for so "it names what it removed" is
proven, not assumed.
Package search/suggestion code (tools/cli/package_commands_search.cpp) is a
user-facing CLI surface even when the change is "just output shaping." Keep the
plain-text and --format json lanes semantically aligned:
pulp suggest filters license-gated packages by default; the
--include-license-gated flag is the explicit inspection path for packages
whose license is rejected or needs review. When changing this lane, keep the
omission counts and human hints in sync with JSON fields so automation and
terminal output tell the same story.package_analyzer_descriptors.{hpp,cpp} is
metadata-only. It maps package provides tokens into
pulp::audio::AnalyzerDescriptor records for UI/control-thread discovery.
It must not install packages, fetch network state, launch tools, or enter
realtime paths. If a new analyzer capability token lands in package metadata,
update the mapping, CLI build list, package command tests, and any package
docs/skills that describe discoverable analyzer providers in the same PR.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.
On multi-config generators (MSVC / Visual Studio), the helper executable lives
under the configuration directory, e.g.
build-windows/tools/import-design/Release/pulp-import-design.exe, while the
delegated relative path remains tools/import-design/pulp-import-design.
Candidate lookup must therefore check both the direct single-config path and
Release / RelWithDebInfo / Debug / MinSizeRel subdirectories, preferring
the config inferred from the running pulp-cpp path when it is itself under
tools/cli/<config>/. Add or keep a focused delegate_to_build_binary test
that asserts the missing-helper diagnostic includes a Release candidate; a
macOS/Linux single-config build will otherwise miss this Windows-only failure
mode.
Cwd independence (2026-05-14): delegate_to_build_binary in
tools/cli/cli_delegate.cpp MUST NOT require cwd to be inside a Pulp
project to find a delegate. Sibling helpers live next to the CLI binary
itself, so the argv[0]-relative resolution path is authoritative and
project-root is a fallback. The original require_project_root() gate
broke cd /tmp && pulp import-design --from claude --file ~/x.html for
no good reason — a first-time user shouldn't need to cd into a pulp
checkout just to translate a design file with absolute paths. When the
delegate truly is missing, the error message lists every candidate path
that was tried and gives the exact cmake --build line to remediate;
do not regress to "Run pulp build first" — that's misleading if the
top-level target doesn't depend on the missing helper.
Delegated exit codes must be decoded (2026-06-02): run() in
tools/cli/cli_common.cpp returns std::system, which on POSIX is a
waitpid status, NOT the child exit code — a child exit of 2 comes back
as 0x200, and propagating it as the CLI's own exit code truncates to 0
(& 0xFF). That silently turned delegated failures into success: e.g.
pulp import-design --export-tokens --format bogus (which the helper
exits 2 for) read as exit 0 through pulp-cpp. run() now routes through
decode_system_status() (WIFEXITED/WEXITSTATUS on POSIX, raw on Windows,
128+signal for signalled children); the spinner-runner does the same.
Any new std::system call that feeds an exit code MUST decode it the same
way. Covered by pulp delegates a non-zero child exit code intact in
test/test_cli_shellout.cpp (which runs against pulp-cpp, the delegate).
Prefer pulp::platform::exec over std::system for quoted-path shell-outs
on Windows (2026-06-14): std::system(cmd) runs cmd.exe /c <cmd>. When
<cmd> starts with a quoted path and contains further quotes (e.g. quoted
arguments and a quoted redirect target — the common shape "C:\tool.exe" --output "C:\out" > "C:\log" 2>&1), cmd /c mis-parses it and aborts with
"The filename, directory name, or volume label syntax is incorrect" before
running anything — so the tool never launches and no output/redirect file is
created. Reproduced against a real Windows host through the CRT system(). The
robust fix is to not go through the shell at all: spawn the tool with
pulp::platform::exec(program, args_vector, timeout_ms), which passes an argv
array straight to the process (no cmd, no quote parsing). pulp kit verify --execute-screenshots (maybe_execute_screenshot_profile in
tools/cli/kit_commands.cpp) hit this — it now uses exec() for both the
direct-.exe path and the .cmd/.bat path (exec("cmd", {"/C", shell_command})).
If you must use std::system (e.g. you genuinely need shell features) and the
command begins with a quoted path, wrap the ENTIRE command in one extra pair of
double quotes (command = "\"" + command + "\"";) so cmd strips exactly that
pair and runs the remainder verbatim.
pulp-screenshotpulp design gallery renders each tagged card by spawning the sibling
pulp-screenshot binary rather than linking Skia/Dawn into the CLI process —
the CLI stays GPU-free and testable, and the render tool owns the backend. Two
conventions apply to any subcommand that does this:
<root>/build/tools/ screenshot, and let the user override with an explicit --screenshot <bin>
(mirrors delegate_to_build_binary's cwd-independent search). If it is not
found, fail with an actionable message or offer a --no-render inventory
mode — never silently emit an artifact full of broken images.pulp::platform::exec(bin, args_vector), not a shell string.
The command begins with a quoted path and carries quoted --output/--script
args — the exact shape std::system's cmd /c mis-parses on Windows (see the
quoted-path note above). The argv array sidesteps the shell entirely and gives
a timed_out flag for the render wall-clock cap.The render output is content-hash cached: the PNG filename embeds
gallery_content_hash(bytes), so an unchanged card at an unchanged viewport maps
to a file that already exists and is skipped. Keep the pure card model
(core/view/src/design_gallery.cpp) free of filesystem/render concerns — the CLI
owns the walk, the cache, and the shell-out; the core only parses tags and
serializes the manifest/HTML.
pulp design splits offline verbs from the live-tool launchercmd_design.cpp is only the verb dispatcher plus the live GPU design-tool
launcher (project discovery, build, watch loop, spawn). The nine pure-data
offline handlers — lint, diff, compile, lint-adherence, record,
gallery, handoff, variants, tweak — live in cmd_design_subcommands.cpp
behind the pulp::cli::design namespace, declared in
cmd_design_subcommands.hpp. To add a new offline pulp design <verb>: add the
run_<verb> handler (and its private helpers) to cmd_design_subcommands.cpp,
declare run_<verb> in the header, and wire one args[0] == "<verb>" branch in
the cmd_design dispatcher. Handlers must not open a window; they read/write
files and call the design cores in core/view. Keep helpers namespace-local so
they stay implementation-private.
For count-like flags such as pulp run --frames, parsers should accept only
plain non-negative decimal digits and reject a leading +. C++ from_chars
acceptance varies by implementation for signed prefixes, and the Rust-facing
CLI surface should stay deterministic across platforms. Add regression tests
for boundary spelling when changing these parsers.
pulp run forwards each launcher flag two ways — as argv AND as an env var —
so the standalone host picks up whichever it reads. When adding a flag here
(cmd_run_parse.cpp parses into ParseRunResult; cmd_run.cpp exports the
env var + builds argv via assemble_launch_args), wire BOTH and add the flag
to the print_help text plus the pulp run --help shellout assertion in
test_cli_shellout.cpp and the parser test in test_cli_run_options.cpp.
The Audio Inspector flags follow this shape: --audio-inspector →
PULP_AUDIO_INSPECTOR=1 (does NOT imply headless); --audio-probe-json <path>
→ PULP_AUDIO_PROBE_JSON=<path> (implies headless, like --screenshot);
--audio-scope-json <path> → PULP_AUDIO_SCOPE_JSON=<path> (+ window/trigger/
channel); --audio-capture-wav <file> → PULP_AUDIO_CAPTURE_WAV=<file> (+
--audio-capture-frames → PULP_AUDIO_CAPTURE_WAV_FRAMES), which dumps the live
output ring to a WAV for pulp audio validate, implies headless, and shares the
single capture FIFO with --audio-inspector / --audio-scope-json (the three
are mutually exclusive at parse time). A bare --audio-probe-json /
--audio-scope-json / --audio-capture-wav run is headless but must NOT
auto-assign a default screenshot PNG path — guard the headless-default branch on
all three being empty. See docs/guides/audio-inspector.md.
The live window also reads display-only waveform env vars:
PULP_AUDIO_INSPECTOR_TRIGGER=rising-zero, PULP_AUDIO_INSPECTOR_GRID=0, and
PULP_AUDIO_INSPECTOR_SCALE=<n>. These are not CLI parse flags and do not
change the probe JSON or audio path.
Headless here means "no visible UI", not "no system audio". pulp run still
launches a standalone host and may activate the live audio device, including the
--audio-probe-json path. Keep the pre-launch stderr notice wired in
cmd_run.cpp, keep PULP_RUN_AUDIO_NOTICE=0 as the explicit quiet-automation
escape hatch, and prefer Audio Doctor / HeadlessHost for no-speaker tests.
pulp import-design --output <path> is the destination for the primary
artifact, not an artifact-kind selector. Keep sidecar anchoring tied to
--output while using --emit {js|ir-json|cpp|swiftui} for the primary artifact
vocabulary. Accepted import-design artifact or runtime values must stay aligned
across pulp_import_design.cpp, docs/reference/cli.md,
docs/reference/design-import.md, docs/status/cli-commands.yaml, and the
import-design skill.
--emit swiftui (baked SwiftUI, Workstream B1) is a fourth lowering parallel to
cpp — baked-only, mirroring cpp's enum/parse/validation/dispatch in
pulp_import_design.cpp (generate_pulp_swift). Its sidecars are per-view —
<RootView>.swift + a <RootView>Theme.swift + <RootView>.bindings.json — so
two imports never collide on a shared theme. --screenshot-backend {skia|coregraphics}
selects the --validate render backend (default skia, the faithful path that
composites file-backed images; see the screenshot skill).
--format is a separate axis from --emit: it picks the token file format,
not the primary artifact kind. Values are w3c (default, DTCG JSON),
css-variables (CSS custom properties; base → :root, .dark-suffixed modes →
@media (prefers-color-scheme: dark); sidecar default flips from tokens.json
to theme.css), and the tailwind/json-tailwind/css-tailwind variants.
Token dispatch is exhaustive on purpose: an unknown --format exits 2, and the
Tailwind variants are gated to --from designmd (they re-parse DESIGN.md for
section context) — on --export-tokens or any non-designmd source they exit 2
rather than silently emitting W3C under the requested name (generalizing them is
Workstream A2). Keep --format values aligned across pulp_import_design.cpp,
the help text, docs/reference/design-import.md, and the import-design skill.
For --emit ir-json, asset-manifest flags are part of that same synced surface:
--allow-network-fetch, --asset-cache, --asset-timeout-ms, and repeated
--asset-hash <uri=sha256>. Keep their help text, YAML manifest rows, reference
docs, and import-design skill text in lockstep. Network asset fetching must
remain explicit opt-in; default JS emission should not fail just because an IR
asset reference points at HTTP(S).
--mode baked is implemented for --emit ir-json and --emit cpp; cpp
requires baked mode. --mode live remains the default and --from jsx --mode live --emit js writes the precompiled bundle verbatim. Because that path does
not parse or render the bundle, it must reject --validate, --reference,
--diff, and --debug with a clear usage error rather than silently bypassing
shared post-processing. When changing this lane, keep the CLI help,
docs/status/cli-commands.yaml,
docs/reference/cli.md, docs/reference/design-import.md, and the
import-design skill aligned. The JSX baked snapshot policy is
--snapshot-semantics {fail|warn|accept}: fail rejects dynamic APIs by
default, warn proceeds with a structured diagnostic, and accept proceeds
silently.
The shipped import-design default is --mode live --emit js. User preference
overrides belong in the existing config surface as
import_design.default_mode (live|baked) and import_design.default_emit
(js|ir-json|cpp), with PULP_IMPORT_DESIGN_DEFAULT_MODE /
PULP_IMPORT_DESIGN_DEFAULT_EMIT as one-environment overrides. Keep C++ and
Rust pulp config, pulp status, the import-design helper, docs, slash
commands, and the MCP status output aligned whenever these keys change. If
only default_mode=baked is configured, ir-json is implied.
Adding a pulp config key: cmd_config.cpp allow-lists every key in three
places — is_allowed_key, validate_value, and the list dump (plus the
usage() help text). Add the key to all of them or set rejects it / list
omits it. Example: [claude] send_user_file (on|off, default on) gates the
plugin's SessionStart hook (hooks/scripts/inject-claude-prefs.sh) that tells
the agent to surface image/file artifacts via SendUserFile. A config-only key
needs no cli-commands.yaml subcommand entry (it's not a new command), but do
update the command summary there + docs/reference/cli.md#config + a
test_cli_shellout.cpp get/set/validate case. Reading config from a hook (bash)
means a small TOML scan — scope the read to the right [section] so a same-named
key in another table can't flip it.
Sidecar output anchoring: when a CLI command takes --output <path>/main.ext and also emits sidecar artifacts (e.g.
pulp import-design writes bridge_handlers.cpp, classnames.json,
tokens.json alongside ui.js), the sidecars MUST default to the same
directory as --output — not cwd. Track an _explicit bool per sidecar
flag and only derive the anchored path when the user didn't set it.
Scattering sidecars to cwd is a first-user trap (we hit it 2026-05-14
during a cd /tmp reimport demo). Test the default-anchored path AND
the explicit-override path so regressions surface.
--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.
--from claude is the worked example.
pulp ship notarize pattern)When a CLI command needs to consume a credential the user wants to
persist outside ~/.pulp/config.toml (e.g. App Store Connect API key,
ASIO licence file, S3 access keys), follow the lane established for
pulp ship notarize --api-key/--api-key-id/--api-issuer:
tools/cli/notary_env.{hpp,cpp} (or a sibling file).
Hand-roll a tiny bash-style KEY=VALUE parser. Support # comments,
blank lines, "double quoted" (with $HOME expansion) and
'single quoted' (literal) values, export KEY=val prefix, and
trailing # inline comment on bare values. Keep it stdlib-only so
the unit test can link it directly without dragging pulp::runtime
into the test binary.getenv lambda. Layer CLI flag >
env var > parsed file > config.toml, and record the source of
each value ("cli" / "env" / "file") so the CLI can print
diagnostics like key-id: ABC (from file). Cred path values must
be redacted to the trailing filename (…/AuthKey_X.p8) — never log
the secret material itself.$HOME/.config/pulp/secrets/<name>.env,
overridable via PULP_<NAME>_ENV env var and a --env-file <path>
flag (the env var is the test hook; the flag is for CI sandboxes).--dry-run that short-circuits before the real side effect and
prints the assembled subprocess argv. Lets the shell-out test
verify resolution without touching the external service.pulp::runtime link),
plus shell-out cases in test/test_cli_*_shellout.cpp covering
--dry-run with CLI flags, with an env file, with CLI overriding
env file, and the no-creds error path.ship/SKILL.md) with the resolution precedence table, and add the
new flags to docs/reference/cli.md + docs/status/cli-commands.yaml.Reference implementation: PR landing feature/asc-notary-key-flow
(2026-05-26). Files: tools/cli/notary_env.{hpp,cpp},
tools/cli/cmd_ship.cpp (notarize block), test/test_notary_env.cpp,
test/test_cli_ship_shellout.cpp ASC-key cases.
pulp ship share)cmd_ship calls itself recursively (cmd_ship({"notarize", "--path", ...}))
to compose stages — share and release both reuse the notarize handler's
credential-resolution chain rather than duplicating it. When you add a flag
like --path to a leaf subcommand, any orchestrating subcommand can thread it
through for free. Conventions that kept this testable without Apple creds:
--dry-run to orchestrators (share) that prints the plan and returns
0 before any codesign/notarytool/spctl call — shellout tests assert the
plan text. Mirror the existing notarize --dry-run / auv3-xcodeproj --dry-run pattern.cmd_ship help/usage,
docs/reference/cli.md, docs/status/cli-commands.yaml, topical skill text,
and parser-error shellout coverage. The share path accepts --output and
--entitlements in addition to credentials/dry-run; if either changes,
update all surfaces together.pulp ship sign --path, describe the argument as an explicit desktop
artifact rather than an Apple-only .app/.dmg/bundle set. The same CLI
primitive dispatches to macOS codesign or Windows signtool; .pkg
installers are intentionally rejected because package signs them at
creation time.ship does NOT need its own top-level slash command or a
commands top-level entry — it lives under the ship entry's subcommands
in cli-commands.yaml and the /ship slash command. cli_sync_check.py
only diffs top-level commands, so a new ship subcommand won't show there;
keep the ship subcommand list in cli-commands.yaml current by hand.Reference: feature/ship-oneoff-notarize (2026-06-01). Files:
tools/cli/cmd_ship.cpp (sign --path, notarize --path, release artifact
selection, share), test/test_cli_ship_shellout.cpp [oneoff] cases,
.claude/commands/ship.md, .agents/skills/ship/SKILL.md.
cli_sdk.cpp's release configure deliberately disables the developer-only
surfaces so shipped SDKs / plugins don't carry them: it passes
-DPULP_ENABLE_AUDIO_PROBES=OFF and -DPULP_ENABLE_INSPECTOR=OFF (the
inspector is the in-plugin authoring / MCP-reachable surface). The scaffolded
project templates (tools/templates/.../build.gradle.kts.template) mirror this.
Keep the two flags together when editing the SDK configure command. A developer
who deliberately wants an inspectable / MCP-reachable plugin re-enables it in
their own plugin build with -DPULP_ENABLE_INSPECTOR=ON. The standalone pulp
CLI and the MCP server (tools/mcp/pulp_mcp.cpp) are separate binaries —
not compiled into a plugin — so this flag never strips them; bundling them
alongside a plugin distribution is a packaging choice.
The SDK build also passes -DPULP_ENABLE_DESIGN_IMPORT=OFF, which strips the
design-import authoring cluster (importers, codegen, lock_to_source,
jsx_lock, token_lock, runtime design-import) from shipped plugins. The
runtime W3C token pair (importDesignTokens / exportDesignTokens, via
core/view/src/w3c_tokens.cpp) stays compiled; WidgetBridge::install_runtime_import_handlers()
becomes a no-op stub. Keep all three strip flags (audio-probes / inspector /
design-import) together in the SDK configure command. Building the test suite
requires PULP_ENABLE_DESIGN_IMPORT=ON (the CMake gate hard-errors otherwise);
a stripped build uses PULP_BUILD_TESTS=OFF.
cmd_*.cpp and command table in pulp_cli.cppcli_common.hpp declarationsCMakeLists.txtcli-commands.yamldocs/reference/cli.mdgrep -r "pulp <name>" .agents/skills/tools/cli/cmd_project_common.cpp and tools/cli/cmd_misc.cpp shell 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
these files; route new call sites through the shared stderr_to_null() or
output_to_null() helpers in tools/cli/shell_redirect.* instead. Otherwise
Windows can leak "The system cannot find the path specified." into stderr,
misreport clean/dirty git state, or fail origin-main probes even though the
same tests pass on macOS/Linux. The pulp status quotes source checkout paths before reading Git metadata test covers the stderr contract locally; Windows
CI is the platform proof for the null-device behavior.
pin is the primary command name; bump survives as a deprecated alias
through cmd_project's dispatch (if (sub == "pin" || sub == "bump")).
When adding a new project subcommand, add the canonical name AND keep
any old alias for one minor release — existing scripts and skill examples
break otherwise.
The Rust user-facing CLI must mirror the same surface in
experimental/pulp-rs/src/cmd/project.rs, experimental/pulp-rs/src/main.rs,
and experimental/pulp-rs/src/help.rs. Do not update only the C++ command:
pin, bump, unpin, and undo all need Rust parser/help coverage too.
pulp project unpin rewrites pulp.toml's sdk_version to "latest"
in-place (single-line value swap, preserving surrounding TOML
structure and comments). Do NOT delete the field — downstream tooling
that greps for it loses a clear signal. The dispatch entry lives at
the same spot as pin/bump/undo and shares find_bumpable_project_root_from.
sdk_version = "latest" is the floating-SDK marker. Resolution happens
in read_sdk_version() (cli_common.cpp) — "latest" becomes the
newest installed version under ~/.pulp/sdk/<x.y.z>/, falling back to
PULP_SDK_VERSION when none are installed. Callers that need to
distinguish the floating marker from a real semver use
read_raw_sdk_version() + is_floating_sdk(). Don't open-code the
"latest" comparison anywhere — both helpers are exported from
cli_common.hpp so the comparison stays in one place.
pulp create writes sdk_version = "latest" by default
(cmd_create.cpp). The --pin flag opts into exact-version pinning
at create time. Both code paths print a discoverable post-create
message about pulp project pin so users learn the opt-in command
without hunting.
The Rust-native pulp create --ci path is a scaffold-only fallback: it
parses the full create flag inventory for help/parity, but full-path flags
such as --pin and --debug only take effect through the delegated C++
create path. Keep those flags modeled in CreateArgs so the native --ci
path can warn when it ignores them instead of silently treating them like
typos.
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 macos — per-PR macOS-runner retargetingtools/cli/cmd_macos.cpp plus .github/workflows/build-macos.yml.
pulp macos retarget --pr N --to <local|namespace|github-hosted> cancels
any in-flight macOS-bearing workflow_runs for PR N (from both build.yml's
matrix and any prior build-macos.yml dispatch) and fires a fresh
gh workflow run build-macos.yml --ref <pr-head> --field runner=<choice>.
Why a separate workflow file (and not just a new pulp pr --retarget-macos
flag): the build.yml matrix couples Linux/Windows/macOS into one
workflow_run. Rerunning macOS via that matrix re-runs Linux/Windows too.
build-macos.yml is independent — it produces its own macos-named
check that supersedes the matrix's macos check by recency. Branch
protection's required-check name stays one stable token.
pulp macos status --pr N reads the latest macos check from the GitHub
check-runs API for the PR's head SHA. Useful for confirming a retarget
landed and which runner pool picked it up.
Repo variables the workflow reads (defined globally for build.yml's
overflow logic; reused here unchanged):
PULP_LOCAL_MACOS_RUNS_ON_JSON — --to localPULP_NAMESPACE_BUILD_MACOS_RUNS_ON_JSON — --to namespace--to github-hosted is always "macos-15", no var needed)When this is the right tool vs. shipyard rescue:
| Goal | Tool |
|---|---|
| Move one PR's macOS to a different pool without disturbing Linux/Windows | pulp macos retarget |
| Move multiple PRs' workflow_runs to a different provider (cancel + redispatch the WHOLE workflow) | shipyard rescue |
pulp overflow — macOS overflow routingtools/cli/cmd_overflow.cpp. Wraps three repo variables that
.github/workflows/build.yml's resolve-provider reads:
| Var | Purpose |
|---|---|
PULP_LOCAL_MACOS_RUNS_ON_JSON | Selector when local has capacity |
PULP_NAMESPACE_BUILD_MACOS_RUNS_ON_JSON | Selector when local is saturated (overflow target; despite the historical name, this is the generic overflow var) |
PULP_LOCAL_MAC_OVERFLOW_THRESHOLD | BUSY count that trips overflow |
Surfaces:
pulp overflow status — show current state, including which runner
the local target points at, the overflow target, threshold value, and
registered self-hosted runners (if the default token can list them).