CLAUDE.md · git:20260702.53014b5 · 2026-07-02 · sha256 aebc2299496e82f1
CLAUDE.md git:20260702.53014b5A
Immutable. This exact content never changes and is served at /api/v1/blob/aebc2299496e82f1.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**sim** is a unified CLI + HTTP runtime that lets LLM agents (and engineers) launch, drive, and observe CAD/CAE simulations across multiple solvers through one consistent interface. It is the "container runtime for simulations" — agents talk to `sim`, `sim` talks to solvers.
The runtime supports two execution modes:
- **One-shot** (`sim run script --solver=X`): subprocess execution, result stored as a numbered run, `sim logs` to browse.
- **Persistent session** (`sim serve` + `sim connect/exec/inspect/disconnect`): a long-lived HTTP server holds a live solver session; agents send code snippets and inspect state without restarting the solver.
The shared runtime skill is bundled at `src/sim/_skills/sim-cli/` and synced into agent skill directories by `sim plugin sync-skills`. Per-solver agent skills ship inside their own `sim-plugin-<solver>` packages.
## Commands
```bash
# Install
uv pip install -e ".[dev]" # core + pytest + ruff
# Tests
pytest -q # unit tests (no solver needed)
pytest tests/test_lint.py # single test file
pytest -q -m integration # integration tests (need solvers + sim serve)
# Lint
ruff check src/sim tests
ruff check --fix src/sim tests
# CLI
sim serve --host 0.0.0.0 # start HTTP server (default port 7600)
sim --host <ip> connect --solver <name> --mode solver --ui-mode gui
sim --host <ip> exec "solver.settings.mesh.check()"
sim --host <ip> inspect session.summary
sim --host <ip> screenshot -o shot.png
sim --host <ip> disconnect
sim run script.py --solver pybamm # one-shot mode
sim logs # list runs
sim logs last --field voltage_V # extract a parsed field
sim check <name> # solver availability
sim lint script.py # validate before running
```
Environment variables: `SIM_HOST`, `SIM_PORT` (CLI client, also `[server]` in config), `SIM_HOME` (global config + history dir, default `~/.sim/`), `SIM_DIR` (project dir, default `./.sim/`).
Config files (issue #5): `~/.sim/config.toml` (global) + `.sim/config.toml` (project). Resolution order `env > project > global > default`. With no config files present, behavior is unchanged from pre-config sim. Run `sim config path | show | init` to manage. See `docs/architecture/multi-session-and-config.md` for the full schema.
## Architecture
### CLI (`src/sim/cli.py`)
Click app with subcommands: `serve`, `check`, `lint`, `run`, `connect`, `exec`, `inspect`, `ps`, `disconnect`, `screenshot`, `logs`. The session-related commands (`connect`/`exec`/`inspect`/`ps`/`disconnect`/`screenshot`) all delegate to `sim.session.SessionClient`, an HTTP client that talks to a running `sim serve`. The non-session commands (`run`, `lint`, `check`, `logs`) work locally without a server.
### HTTP server (`src/sim/server.py`)
FastAPI app exposing:
- `POST /connect` — launch a solver, register a new session in `_sessions: dict[str, SessionState]` keyed by session_id
- `POST /exec` — `exec()` a Python snippet against the live `session`/`meshing`/`solver` namespace for the session selected by `X-Sim-Session` header (or the single live session if unambiguous); capture stdout/stderr/return value, append to that session's runs
- `GET /inspect/<name>` — query `session.summary`, `session.mode`, `last.result`, `workflow.summary` (session-scoped)
- `POST /run` — one-shot script execution (no session required)
- `GET /ps` — list of all live sessions + default_session (set only when exactly one live)
- `GET /screenshot` — base64 PNG of the server's desktop
- `POST /disconnect` — tear down the session selected by `X-Sim-Session` (or the sole live session)
- `POST /shutdown` — tear down all sessions, exit the server process
The server supports multiple concurrent sessions keyed by session_id. Each `SessionState` carries its own `threading.Lock` so exec/inspect against different sessions can run in parallel. A single solver name can only be live once (driver instances are module-level singletons).
**`sim serve --reload` drops all sessions on any source change under the watched tree.** uvicorn's reload watchdog observes file mtimes in `src/sim/**`; any edit (git pull, scp of a modified driver, even touching an unrelated module) restarts the worker, wiping `_sessions`. Child solver processes (out-of-process GUIs, separately spawned solver binaries) survive the reload because they're spawned separately, but the session handles to them are gone — you have to `connect` again. Driver temp files written into the solver's workspace live outside `src/` so they don't retrigger. Practical rules:
- Don't edit driver code mid-experiment; finish the run, then edit.
- For long autonomous experiments where you're editing driver code iteratively, launch **without** `--reload` and restart manually when you want the new code picked up.
- Reconnecting after a reload can take tens of seconds for GUI-mode drivers that re-adopt the existing window rather than relaunching it.
### Driver protocol (`src/sim/driver.py`)
`DriverProtocol` (a `runtime_checkable` `Protocol`):
- `name: str` — registered driver name
- `detect(script) -> bool` — does this script target this solver?
- `lint(script) -> LintResult` — pre-execution validation, returns `Diagnostic`s
- `connect() -> ConnectionInfo` — package availability + version check
- `parse_output(stdout) -> dict` — extract structured results (convention: last JSON line on stdout)
- `run_file(script) -> RunResult` — one-shot execution
`LintResult`, `Diagnostic`, `RunResult`, `ConnectionInfo` are dataclasses with `to_dict()` for JSON serialization.
### Driver registry (plugin entry points)
`sim-cli-core` is solver-agnostic and ships with no in-tree solver drivers. Drivers are provided by installed plugin packages that expose the `sim.drivers` entry-point group. Use `sim plugin list` and `sim plugin info <name>` to inspect the plugins visible in the active environment.
Plugin implementation work belongs in the owning `sim-plugin-<solver>` repository, where the package metadata, `DriverProtocol` implementation, `compatibility.yaml`, bundled skills, and plugin tests live. Do not add solver drivers under `src/sim/drivers/<name>` in this repo.
A driver may set `supports_session = True` to implement the persistent-session lifecycle (`launch`/`run`/`query`/`disconnect`); the rest are one-shot only. `get_driver(name)` looks up by `.name` attribute and lazily imports the implementation module on first use, so a broken plugin does not crash the CLI.
### Execution pipeline — one-shot (`run`)
1. `cli.run` → `runner.execute_script(script, solver, driver)` → subprocess, captures stdout/stderr/duration
2. `driver.parse_output(stdout)` → extract structured fields
3. `history.append({cwd, solver, session_id, run_id, ...})` → single jsonl line in `~/.sim/history.jsonl`
4. `sim logs <id>` reads back via `history.get_by_id`; `sim logs --solver X --all` filters
### Execution pipeline — persistent session (`exec`)
1. `cli.connect` → HTTP `POST /connect` to server → `driver.launch(...)` → new `SessionState` added to `_sessions`; response carries the session_id which the client stores
2. `cli.exec` → HTTP `POST /exec` with code + `X-Sim-Session: <id>` → server routes to that session, then `_execute_snippet()` runs `exec(code, namespace)` where `namespace` has `session`, `meshing`/`solver`, `_result`
3. `cli.inspect <name>` → HTTP `GET /inspect/<name>` (session-scoped) → driver- or session-specific query
4. `cli.disconnect` → HTTP `POST /disconnect` (session-scoped) → driver-specific teardown, remove from `_sessions`
Session routing rules: an explicit `X-Sim-Session` header wins (404 if unknown); otherwise the server falls back to the sole live session; otherwise `/exec` returns 400. Clients can also set `SIM_SESSION` env var or pass `sim --session <id> ...` to scope a whole CLI invocation.
## Adding or changing a solver driver
Create or update solver drivers in the owning `sim-plugin-<solver>` repository. A plugin should implement `DriverProtocol`, register the driver through `[project.entry-points."sim.drivers"]` in its `pyproject.toml`, and carry its own compatibility metadata, bundled skills, and tests.
The core CLI should only change when the shared runtime contract or plugin discovery machinery changes. Before editing driver-facing docs or behavior, inspect the installed environment with `sim plugin list` and `sim plugin info <solver>`, then work in the plugin repo that owns that solver.
**Prefer generic primitives over task-specific ones.** Before adding a new
driver method purpose-built for one workflow (a resumable-sweep runner, an
auto-coupling helper, etc.), check whether bounded `run(timeout_s=...)` +
`health()`/progress inspection + the solver's own retained state already let
the agent script that workflow step-by-step. Add a new dedicated primitive
only for a genuine generic gap proven by a real session (missing timeout
guard, no orphan-process cleanup, no liveness check) — not for workflow
convenience alone. See `sim-studio-desktop` issues #62/#80 for the HFSS case
this pattern was drawn from.
## Test Layout
```
tests/
__init__.py
conftest.py shared fixtures / execution paths
base/ core framework tests (no solver needed)
test_cli.py smoke tests for click commands
test_compat.py skills layering / profile resolution
test_config.py two-tier config resolution
test_connect.py driver.connect() availability checks
test_driver_discovery.py entry-point plugin discovery
test_history.py global run history persistence
test_lint.py lint protocol coverage
test_logs.py sim logs CLI
test_multi_session.py session routing + concurrency
test_run.py one-shot subprocess execution
fixtures/ mock scripts and shared test assets
```
Solver plugin tests live in their own plugin repos. Tests in this repo cover the shared CLI/runtime contract and plugin discovery behavior.
## Notes
- Global run history lives in `~/.sim/history.jsonl` (append-only; override dir via `SIM_HOME`); git-ignored
- The server supports multiple concurrent sessions keyed by `X-Sim-Session` header; a solver name can only be live once per server process (driver instances are module-level singletons)
- Project uses `uv` for dependency locking (`uv.lock`)
- The shared runtime skill lives at `src/sim/_skills/sim-cli/`; per-solver skills ship in their `sim-plugin-<solver>` packages (synced via `sim plugin sync-skills`)
## Releases
- **PyPI distribution name:** `sim-cli-core` (renamed from `sim-runtime` in Phase 4 — see `src/sim/plugins.py` dual-lookup; `sim-runtime 0.2.1`–`0.2.3` remain on PyPI but are no longer released against). Both names were rejected for `sim-cli` itself, which is too similar to the existing `simcli` placeholder.
- **Console script + import name:** `sim`. The PyPI dist name and the import name intentionally differ; `src/sim/__init__.py` looks up `version("sim-cli-core")` first, falling back to `version("sim-runtime")` for editable installs predating the rename, and `try/except PackageNotFoundError` for source checkouts.
- **Trusted publisher:** GitHub OIDC, repo `svd-ai-lab/sim-cli`, workflow `.github/workflows/publish.yml`, environment `pypi`. Configured at https://pypi.org/manage/project/sim-cli-core/settings/publishing/.
- **Tag format:** `v<MAJOR.MINOR.PATCH>` matching `pyproject.toml` `version` exactly. Always tag from `main` after PR-merging a release branch.
- **Don't skip the clean-venv smoke test before tagging.** 0.2.1 shipped broken (`__init__.py` referenced `version("sim-cli")` after the rename) because no one ran `sim --version` in a fresh venv before pushing the tag. Twine check verifies packaging, not import.
## Public-artifact privacy / license safety
When writing anything that lands in a *public* place — GitHub issues,
PR titles/bodies/comments, public commit messages, public docs — keep
**engineering-relevant** facts and drop **diary-style disclosure**
that ties a specific commercial-software install to a specific
machine or account. The two are easy to confuse.
**Keep:**
- The bug, the error message, the exit code, the reproducing input.
- Version info that is a genuine reproduction prereq — when a behavior
is gated to a specific release of an open-source dependency, the
version is part of the engineering claim and stays. Use neutral
framing for closed-source dependencies ("a CFD solver release that
changed the boundary-condition API") rather than naming the vendor
and release.
- Platform when behavior is platform-gated (Linux vs Windows
filesystem casing, COM availability, etc.).
**Drop / replace:**
- Personal usernames and hostnames → "a Windows test host", "a
development machine", or elide.
- Personal IPs (including Tailscale `100.90.x.x`) → elide.
- Personal filesystem paths (`C:\Users\<you>\...`,
`~/Documents/GitHub/...`, `C:\Python<NN>\...`,
`C:\Program Files\<Vendor>\<version>\...`) → "a local clone",
"the editable install", or elide.
- Specific commercial-software *versions tied to a personal machine*
— vendor compliance teams treat these as license-audit signals
even when the version alone would be fine. Replace with neutral
phrasing.
- Tailscale tailnet names, OS account SIDs, MAC/serial numbers.
Sanitize existing artifacts by editing PR/issue/comment bodies
(`gh pr edit`, `gh issue comment --edit-last`). Avoid force-pushing
to rewrite commit-message history unless the disclosure is severe
*and* the branch is unmerged *and* not being collaborated on.