leonard_cli 0.4.1
leonard_cli: ^0.4.1 copied to clipboard
CLI frontend for Leonard.
leonard_cli #
Command-line entrypoint for Leonard. Connects to a running Flutter VM service, drives the perception-action loop, and streams a trajectory file to disk.
Model providers #
The --model flag selects a ModelTier which
the provider_factory maps to a
concrete ModelProvider:
| Tier | Provider | Required env |
|---|---|---|
claude |
AnthropicModelProvider |
ANTHROPIC_API_KEY (default) |
qwen-mlx |
SwiftInferModelProvider (local swift-infer gateway) |
SWIFT_INFER_AGENT_TOKEN (when the gateway requires auth), SWIFT_INFER_ENDPOINT (optional, defaults to http://localhost:8080), SWIFT_INFER_MODEL (optional, defaults to qwen3.6-35b-a3b-8bit), SWIFT_INFER_REASONING_EFFORT (optional), SWIFT_INFER_MAX_TOKENS (optional, defaults to 16384) |
openai |
OpenAiModelProvider |
OPENAI_API_KEY |
swift-infer gateway (qwen-mlx) #
When --model qwen-mlx is selected, the CLI talks to the user's local
swift-infer gateway. The wire contract is intentionally identical to
fs agent (factoryskills' agent implementation in
factoryskills/internal/agent/agent.go) so the same gateway and
inspection tooling work for both clients.
Environment variables #
SWIFT_INFER_AGENT_TOKEN— forwarded asAuthorization: Bearer <token>. Same namefs agentuses; share one shell export for both. When unset (or empty), the CLI sends an unauthenticated request — useful when pointing at a gateway that has auth disabled, but the production gateway requires this.SWIFT_INFER_ENDPOINT— base URL of the gateway. Defaults tohttp://localhost:8080when unset or empty.SWIFT_INFER_MODEL— model id requested from the gateway. Defaults toqwen3.6-35b-a3b-8bitwhen unset or empty. Set it to any node the gateway serves (curl "$SWIFT_INFER_ENDPOINT/v1/models"lists them), e.g.qwen3.8-40b-a3b-8bit. The--model-idflag outranks this variable, which outranks the default; capabilities resolve from the id itself, so anyqwen3.*node keeps vision + preserved thinking.SWIFT_INFER_REASONING_EFFORT—reasoning_effortsent on every/v1/messagesrequest when resolved: one ofnone,low,medium,high,xhigh. Unset (or empty) means the field is omitted and the node's model-card default applies — except for aqwen3.8*model id, which defaults tomediumbecause that template otherwise runsxhigh. The--reasoning-effortflag outranks this variable; an unparseable value fails the run loudly.SWIFT_INFER_MAX_TOKENS—max_tokensfor a single response. Defaults to16384(thinking plus answer share this budget). The--max-tokensflag outranks this variable; a non-positive or non-numeric value fails the run loudly.
Every other sampling knob (temperature, top_p, top_k,
presence_penalty, repetition_penalty) is deliberately NOT sent by the CLI,
so the swift-infer node's per-model card values apply. preserve_thinking is
always sent — it is a reasoning-replay contract, not a sampling knob.
Per-run conversation tracing #
Every run mints a stable sessionId (cli-<utc-iso8601> slugged to
header-safe characters) and stamps every request with:
X-Session-Id: <sessionId>X-Conversation-Id: leonard-<sessionId>-<unixMs>— one conversation per run, groups every turn for inspection. Mirrorsfs agent'sfsagent-<beadID>-<unixtime>convention.X-Swift-Infer-Capture-Bodies: true—captureBodiesis on by default for dev/PoC. The gateway captures both the request and response bodies soGET $SWIFT_INFER_ENDPOINT/v1/conversations/<id>returns the captured turn for inspection without re-running the agent.Accept: text/event-stream— SSE streaming for live<think>…</think>surfaces.
Example (default — cloud backend):
export ANTHROPIC_API_KEY=sk-ant-…
dart run leonard_cli \
--vm-uri ws://127.0.0.1:54321/abc=/ws \
--goal "open settings"
Example (opt-in — local swift-infer):
export SWIFT_INFER_AGENT_TOKEN=sk-…
export SWIFT_INFER_ENDPOINT=http://localhost:8080 # optional
export SWIFT_INFER_MODEL=qwen3.8-40b-a3b-8bit # optional
export SWIFT_INFER_REASONING_EFFORT=medium # optional
export SWIFT_INFER_MAX_TOKENS=16384 # optional
dart run leonard_cli \
--model qwen-mlx \
--vm-uri ws://127.0.0.1:54321/abc=/ws \
--goal "open settings"
# Inspect captured turn:
curl "$SWIFT_INFER_ENDPOINT/v1/conversations/leonard-cli-…"
Run summary, tokens and cost #
Every trajectory records the provider's token counts and model latency: per
turn under model_metadata.usage, and for the whole run under the footer's
usage (see the leonard_agent README for the shapes). Retries count as
calls. For CI, --summary-json <path> also writes one small JSON file per
run, so totals can be collected without parsing trajectories:
{
"schema": "leonard.run_summary/1",
"exit_code": 0,
"outcome": "done",
"total_turns": 9,
"total_duration_ms": 48000,
"model_identifier": "claude",
"trajectory": "trajectories/2026-10-17T10-00-00Z.jsonl",
"usage": {"model_calls": 10, "input_tokens": 141000, "output_tokens": 2600, "estimated_cost_usd": 0.462, "...": "..."}
}
The cost estimate uses a dated built-in table of list prices for the hosted
models. A local qwen-mlx run, or a model not in the table, reports
estimated_cost_usd: null. To price a run yourself (an unlisted model, a
negotiated rate, or amortized owned hardware), pass dollars per million
tokens with --price-input-per-mtok and --price-output-per-mtok, or set
LEONARD_PRICE_INPUT_PER_MTOK and LEONARD_PRICE_OUTPUT_PER_MTOK. The flags
outrank the environment, and both values of a pair must be set.
Runs also record time to first token and output tokens per second, which is the figure that matters when sizing local hardware.
Captured frames and image goldens #
Every live-driving run writes PNG frames already carried by its trajectory turns. By default, the frame directory is derived from the trajectory path:
trajectories/
├── 20260507T141503Z.jsonl
└── 20260507T141503Z.frames/
├── turn-0000.png
└── turn-0001.png
The same rule keeps frames inside the station's existing per-run directory:
panel-selfdrive-20260507T141503Z/
├── outer.jsonl
├── outer.frames/
│ ├── turn-0000.png
│ └── turn-0001.png
├── driver.log
└── driver.status
--frames-dir <dir> overrides only frame placement. The CLI never deletes or
prunes frame or run directories; the caller owns retention. With no
--goldens-dir, the run is capture-only.
To compare the current run against PNG baselines, supply their directory:
dart run leonard_cli \
--vm-uri ws://127.0.0.1:54321/abc=/ws \
--goal "open settings" \
--goldens-dir image_goldens
Frames are paired with goldens by filename. Image dimensions must match. A
pixel differs when any RGBA channel delta exceeds
--golden-channel-tolerance (default 8), and the frame fails when the
differing-pixel ratio exceeds --golden-max-diff-ratio (default 0.0).
To establish or replace baselines, run:
dart run leonard_cli \
--vm-uri ws://127.0.0.1:54321/abc=/ws \
--goal "open settings" \
--goldens-dir image_goldens --update-goldens
Update mode creates the baseline directory, overwrites same-named files, and
prints every baseline written. It never compares in the same invocation. The
repository's comparator fixtures are PNGs under
packages/leonard_cli/test/image_goldens/; they are distinct from the JSON
observation goldens used by the Flutter perception-equivalence tests.
Nightly dogfood #
The nightly e2e test (packages/leonard_agent/integration_test/dogfood/dogfood_e2e_test.dart) is
self-pinned to local inference via its own SwiftInferConfig construction and does not
depend on leonard_cli's --model default. The launchagent that runs the nightly
test (scripts/launchd/run-dogfood.sh) invokes dart test directly on the e2e test
file, bypassing the CLI entirely, so the default change has no effect on nightly behavior.
See also #
factoryskills/internal/agent/agent.go— the reference implementation of this wire contract. lenny's swift-infer client is intentionally header-for-header symmetric withfs agent; if you add a new header to one, add it to the other.lib/src/provider/swift_infer/swift_infer_config.dart(inleonard_agent) — provider-side config surface, including theextraHeadersforward-compat bag.