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.