Skip to content

A missing uv is a refusal that names the fix, not a traceback - #311

Merged
marcociav-exmergo merged 7 commits into
mainfrom
issue-310
Aug 15, 2026
Merged

A missing uv is a refusal that names the fix, not a traceback#311
marcociav-exmergo merged 7 commits into
mainfrom
issue-310

Conversation

@marcociav-exmergo

Copy link
Copy Markdown
Member

A missing uv is a refusal that names the fix, not a traceback

Closes #310.

The skill wrappers shell into uv run to install and run the engine, and did so
with no guard, byte-identically in all three. On a machine without uv on PATH
the first run ended here:

$ PATH=/usr/bin:/bin python3 skills/explore/scripts/run.py explore inventory
Traceback (most recent call last):
  ...
FileNotFoundError: [Errno 2] No such file or directory: 'uv'

Nothing said uv was required either: not the plugin manifest, not the skill
frontmatter, not the install sections of either README. The documented Claude Code
path is two /plugin commands and then "the skills appear and auto-trigger", so a
user who followed it exactly could land on a stack trace, and the person most
likely to be standing there is a Claude Code user rather than a Python developer.

dex collects no telemetry by design, which is what makes this worth fixing
pre-emptively rather than on a report. Every user who hit it churned invisibly, and
the report was never going to arrive.

Two failure surfaces, and only one of them is catchable

The wrapper executed directly (python3 .../run.py, the AGENTS.md any-agent
path, any host that calls the script) runs our code, so it can refuse. It now does:

$ PATH=/usr/bin:/bin python3 skills/explore/scripts/run.py explore inventory; echo "exit=$?"
{"status": "error", "data": {}, "cost": {"paradigm": null, "estimate": null, "ceiling": null},
 "warnings": [], "diffs": [], "errors": ["dex runs its engine through uv, which was not found
 on PATH. Install it with: curl -LsSf https://astral.sh/uv/install.sh | sh (or `brew install
 uv`, or `pipx install uv`), then re-run."], "reason": "prerequisite"}
exit=1

uv run .../run.py, which is what every SKILL.md tells the agent to run,
fails in the shell with uv: command not found before a line of ours executes. No
guard inside the script can catch that, so the fix there is documentation aimed at
the agent rather than at the user: each SKILL.md now says what that message means,
what to tell the user to install, and not to work around it. That last clause is
the load-bearing one. An agent that cannot run the wrapper will otherwise reach for
raw Python or a database CLI, and every guardrail lives in the engine, so any other
path is unguarded. The three skills each name their own version of the wrong move
(raw SQL, hand-editing the dbt project, eyeballing drift) rather than sharing one
generic warning.

The envelope shape is the engine's, not the issue's sketch

The issue body sketches {"ok": false, "error": {"code": "uv_not_found", ...}},
which is not a shape dex has ever emitted; its prose asks for "the same sanitized
JSON envelope shape the engine emits", and that is the one above. Confirmed before
building. A caller reads this exactly like any other refusal instead of learning a
second format for one case.

reason: prerequisite is not a new classification either. It is what the engine
already assigns to a missing dependency the user installs and retries, the same
reason DemoDependencyError and DialectDependencyError carry through
envelope._reason_overrides.

This is the one envelope in the product built by hand rather than through
exmergo_dex_core.envelope, because the engine that would build it is precisely
what has not been installed yet. That is a real drift risk, so a test holds the two
in step: it constructs Envelope(status=ERROR, errors=[...], reason=PREREQUISITE)
and asserts the wrapper's literal equals its model_dump(mode="json"). It sits
behind importorskip, so it skips in CI's engine-less uvx pytest evals job and
runs everywhere the engine is present. Verified passing with the engine importable
rather than left to skip silently.

Tests

In evals/tests/test_wrapper.py, the established home for wrapper tests.

  • The refusal is proven in all three wrappers by behavior, parametrized over
    _SKILLS, not left to the existing byte-identity assertion. This guard is exactly
    the kind of thing a refactor drops from one copy, which the issue calls out.
  • subprocess.call is patched to a raiser rather than a stub, so a guard that let
    execution through fails loudly here instead of quietly shelling out to a uv that
    is not there.
  • Stdout parses as exactly one JSON object with exactly the seven envelope keys,
    main() returns 1 (the engine's own exit code for an error envelope), and the
    message names uv and an install command, since a user who is not a Python
    developer has to be able to act on it without reading our source.
  • The ordinary path is covered too: with uv present the guard does not fire, the
    argv still begins ["uv", "run", ...], and nothing of the wrapper's own reaches
    the single-envelope stdout the engine owns.

Files

Engine. None. The engine is untouched.

Wrappers. skills/{explore,transform,maintain}/scripts/run.py: json and
shutil added to the stdlib imports, the guard inline at the top of main(), and
a docstring paragraph stating the prerequisite. Edited once and copied, so the three
stay byte-identical. The wrapper's constraint is that it runs before the engine is
installed, not that it stays minimal, so two more stdlib imports cost nothing.

Tests. evals/tests/test_wrapper.py, five tests and a shared driver.

Docs. Each SKILL.md under "How to drive it"; README.md gains a
## Prerequisite: uv section covering both install paths, since npx skills add
needs it as much as the plugin does; AGENTS.md states it for the any-agent
contract, which documented uv run twice and never said so; references/command-contract.md
records that the envelope contract holds even on the one path that runs before the
engine exists; CHANGELOG.md under [Unreleased] / Fixed.

Not changed. .claude-plugin/plugin.json has no prerequisites field.
scripts/prepare_release.sh and .github/workflows/release.yml sed and grep
DEX_CORE_VERSION, which this does not move. The .cursor and .windsurf rule
files document uv run python -m exmergo_dex_core and defer to AGENTS.md.

Out of scope, per the issue

Falling back to python -m exmergo_dex_core when the engine is already importable
and uv is absent. It carries its own questions (which interpreter, which extras,
how the pinned version is honoured), and a clear refusal that names what to install
is a complete fix for the reported failure.

Also considered and dropped: changing the documented invocation from uv run to
python3, which would make the guard reachable on the documented path. It trades a
missing-uv failure for a missing-python3 one (a bare macOS without the Xcode
command line tools has only a stub), so it widens the blast radius to fix a message.

Verification

  • The issue's exact reproduction now returns the envelope above with exit=1.
  • uvx pytest evals -q: 33 passed, 1 skipped (the engine cross-check). The same
    file under the engine: 28 passed, 0 skipped, so the cross-check ran and passed.
  • packages/dex-core: 2178 passed, 65 skipped, unaffected.
  • Ruff check and format clean; check_no_em_dashes.py clean.
  • Happy path from a temp directory through two different wrappers: demo creates
    dex_demo.duckdb and .dex/config.yml, explore inventory --rank returns 7
    objects. No regression on a machine that has uv.
  • md5 skills/*/scripts/run.py identical across the three.

@marcociav-exmergo
marcociav-exmergo merged commit 58a66f8 into main Aug 15, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The skill wrappers require uv but never say so, and fail with a raw traceback when it is missing

1 participant