AGENTS.md@src/partcad_cli · git:20260826.2633453 · 2026-08-26 · sha256 1628ebf2d9eb250b
AGENTS.md@src/partcad_cli git:20260826.2633453A
Immutable. This exact content is served forever at /api/v1/blob/1628ebf2d9eb250b.
# partcad-cli CLI interface (`pc` / `partcad` commands) to most `partcad` core functionality. Source: `./src/partcad_cli`. Tests: `./tests/partcad_cli`. It is one of the packages inside the single `partcad` wheel, not a distribution of its own; run all commands below from the repo root unless noted. `pc daemon start` / `pc daemon stop` manage the per-workspace background daemon from [`partcad_service_json_rpc`](../partcad_service_json_rpc), through [`partcad_client`](../partcad_client): `start` goes through `partcad_client.client.start_daemon()` (forwarding the daemon-affecting globals — `--offline`, `--force-update`, `--python-sandbox`, verbosity — which otherwise stop at the client's own `user_config`), while `stop` calls `partcad_client.daemon.stop_daemon()`. These two are also the VS Code extension's way in. It does not derive socket paths or probe liveness itself: it runs `pc daemon start`, reads the endpoint from stdout, and connects — so there is one implementation of "where is the daemon", not one per language. ## Command boundary Command bodies are thin clients of that daemon (`click/service.py::run`) unless they cannot be. A command belongs to the **daemon** when it reads or mutates the package graph, or when it drives a CAD wrapper — the wrapper's Python runtime lives in the daemon's environment and may not exist on the client at all. That includes commands with file arguments (`add`, `import`, `convert`): the client sends an absolute path, the daemon rejects anything outside the package, and paths are printed relative to the package that owns them, so the output never depends on a working directory. A package-mutating command *must* be a daemon client, or the daemon's warm context keeps serving the pre-mutation package. **`pc update` and `pc upgrade` sit on opposite sides of this line, which is why they are two commands and not one command with a flag.** `pc update` refetches the packages a package imports — the package graph, so a thin daemon client like any other. `pc upgrade` replaces this machine's copy of PartCAD (`partcad_client.selfupdate`), which only the process running from it can do: a daemon can be remote, where "upgrade PartCAD" would mean upgrading somebody else's installation. It stays within the boundary's letter as well as its spirit — `selfupdate` lives in the deliberately cheap `partcad_client`, so the command never imports the heavy `partcad`. `pc upgrade` owns the daemon handling the upgrade needs, because `selfupdate` deliberately has none. Every daemon on this machine is executing the files about to be replaced, so it stops **all** of them and waits (`daemon.stop_all_daemons()`) through the `before_install` hook — after a newer version is confirmed, before anything is written, so a no-op upgrade costs nobody their warm context. Doing that from a client is what keeps it simple: one process acting on its own machine, rather than daemons policing each other. A survivor is reported rather than fatal, because the new version is installed beside the old one and the old one is not removed until the command exits. The VS Code extension's "Update PartCAD" runs `pc upgrade`, so the two cannot drift apart. **`pc lint` sits on both sides of the line, one mode each.** `pc lint [-P/-r]` checks a *package*: which packages, resolved how, with which files, is the package graph, so it is a thin daemon client like any other. `pc lint --file` checks the *files named on the command line*, in this process: an ASSY file is a Jinja2 template rendered to YAML and matched against a schema, which needs no package graph, no CAD runtime and no context — and with `--stdin` the content is a buffer an editor has not saved, which the daemon cannot see at all. There is deliberately no RPC method for it: sending it would ship the client's own file across a wire to have it read back, and would leave the editor silent exactly when the package fails to load *because* of that file. The checker (`partcad_client.lint`, over `partcad_utils.assy_lint`) is the same one the daemon runs over a package, so an editor and CI cannot disagree. The VS Code extension runs `pc lint --file`, so the two cannot drift apart either. A command stays **in-process** only when it operates on the client's own state, which does not cross the wire: `init` (creates the workspace, before any package or context exists, and adds the `Render` command to the repository's `.vscode/launch.json` — see `src/partcad/launch_config.py`; the daemon's `init` operation does the same, so both entry points leave the same repository behind), `config` (prints the client's resolved `user_config` with its `--threads-max`/`PC_*` overrides), `healthcheck` (diagnoses this host), and **all of `pc system ...`** — `system status`, `system reset` and `system set telemetry ...` act on the machine the CLI runs on, by definition: its internal state directory, its user configuration — and `upgrade`, which replaces that machine's installation. Still unmigrated: `supply/*`, `add sketch`, `add dep`. `pc daemon ...` is the other side of that pair, command for command: `daemon start|stop` manage the process, while **`daemon status`**, **`daemon reset`** and **`daemon set telemetry ...`** are the daemon-side counterparts of the `pc system` commands of the same name — they report and change the daemon's own internal state directory and configuration, not the client's. The two coincide today, because the daemon runs on the same machine; they will not once a daemon can be remote, which is why both halves exist. `daemon reset` clears the daemon's state directory and the warm contexts that reference it. It runs unconditionally, because the caller has already decided and a background daemon has nobody to ask for confirmation; a destructive confirmation, when one is wanted, belongs in the client, before the call. (The daemon and the CLI share a machine today, so the two state directories coincide; they will not once a daemon can be remote, which is why the commands are separate. `daemon reset` carries a TODO to gate it behind access control before that happens.) ## Whose user configuration the daemon works under The client's whenever the client sends one — as of the moment the command ran. `service.py::run` resolves the CLI's own `user_config` (file + `PC_*` environment + command line) and sends a copy of it, `UserConfig.to_dict()`, with every `context.create`; the daemon rebuilds it with `UserConfig.from_dict()` and builds the context from *that*, never from `pc.user_config`. A client that sends no `userConfig` leaves the daemon on its own configuration instead (see below). This is not a nicety. The daemon is warm and shared per workspace, so its own configuration is whatever the environment held when something first started it — possibly days ago, possibly from a VS Code window. Reading options there would silently drop every `--devel-index`, `--force-update`, `--offline` and `PC_*` the command was actually invoked with, and there is no launch argument that can fix it because the daemon is usually already running. Adding a user-configuration option therefore means adding its key to `OPTION_KEYS` (or `SECTION_PATHS`) in `partcad_utils/user_config.py`; an option missing from those lists is one the daemon keeps resolving from its own environment. The daemon keeps the configuration each warm context was built from (`session.context_user_configs`) and rebuilds the context when a caller's differs, because a package graph resolved under one configuration cannot answer for another. A client that sends no configuration — the VS Code extension, which configures the daemon once through its launch arguments — keeps getting the daemon's own. PartCAD **never prompts** for anything mid-operation. Credentials for private Git dependencies are configured upfront under `git.auth` in the user configuration, and `GitCallbacks` fails with a message naming that setting when they are missing — a prompt inside a background daemon or a CI job is a hang, not a question. `git.auth` travels in the configuration copy for the same reason as everything else, and the git helpers take the context's configuration rather than the process-wide singleton so the copy is what actually authenticates. Both halves of this split are enforced by `tests/unit/test_command_boundary.py`, which also checks that every method name a command sends exists in the daemon's registry. The in-process and unmigrated lists live at the top of that file; update them there when a command intentionally moves. ## Setup All commands on this page run **inside the dev container**, not on the host — see "Where commands run" in the root [AGENTS.md](../../AGENTS.md) for how to enter it. Dependencies are already installed in the image; re-run `poetry install` only after changing `pyproject.toml`. The virtualenv is not auto-activated, so prefix the commands below with `poetry run` (e.g. `poetry run pytest ...`, `poetry run pc ...`). ```bash poetry install # from repo root; installs the whole `partcad` wheel in editable mode ``` ## Test and validate changes Two validation steps are required for any change under `src/partcad_cli/` — both must pass, unit tests alone are not sufficient because CI also gates on the example run: 1. Unit tests: ```bash pytest tests/partcad_cli -x -p no:error-for-skips -p no:warnings --dist no # matches CI (test-pytest job) ``` 2. End-to-end CLI validation against the example projects (matches CI's `test-examples-partcad` job in `.github/workflows/test.yml`): ```bash cd examples pc list all -r //pub/examples/partcad pc test -r --package //pub/examples/partcad pc render -r --package //pub/examples/partcad ``` If `pc`/`partcad` isn't resolvable even under `poetry run`, run the module directly instead: `poetry run python -m partcad_cli.click.command --no-ansi <same args>`. ## Manual CLI exercise Under `poetry run`, the CLI is available as `pc` or `partcad`: ```bash pc version pc list all -r //pub/examples/partcad # from ./examples, or any dir with a partcad.yaml ``` ## Lint / format ```bash black --check src/partcad_cli tests/partcad_cli flake8 src/partcad_cli tests/partcad_cli isort --check src/partcad_cli tests/partcad_cli ``` ## Commit `pre-commit` hooks (`dev-tools/pre-commit-config.yaml`) run `pytest`, formatting, and lint checks on commit and are required to pass in CI before a PR can merge.