diff --git a/.pre-commit-hooks/regen-doc-snippets.py b/.pre-commit-hooks/regen-doc-snippets.py index 28f41a66..327474ea 100755 --- a/.pre-commit-hooks/regen-doc-snippets.py +++ b/.pre-commit-hooks/regen-doc-snippets.py @@ -102,6 +102,7 @@ class Snippet: Snippet(CLI_FILES / "project-venv-remove-help.txt", ("project", "venv", "remove", "--help")), Snippet(CLI_FILES / "project-venv-path-help.txt", ("project", "venv", "path", "--help")), Snippet(CLI_FILES / "project-venv-shell-help.txt", ("project", "venv", "shell", "--help")), + Snippet(CLI_FILES / "project-venv-run-help.txt", ("project", "venv", "run", "--help")), Snippet( CLI_FILES / "project-manifest-rebuild-help.txt", ("project", "manifest", "rebuild", "--help"), diff --git a/UNRELEASED.md b/UNRELEASED.md index 938433da..1a1d34d2 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -24,3 +24,9 @@ dependency group. `typos` now auto-fixes on commit, and the redundant mistakes are caught before git-cliff folds commit subjects into the CHANGELOG — which is itself no longer excluded from the check. Existing clones should re-run `prek install --install-hooks` once to pick up the new `commit-msg` hook. + +`toolr project venv run -- ` runs a command inside the managed tools venv — +the first-class one-liner for running a command-package's tests +(`toolr project venv run -- pytest tools/`). By default it syncs the venv first +(like `venv shell`); `--no-sync` runs against the existing venv and errors if it +is missing or stale, for deterministic CI. diff --git a/crates/toolr/src/builtin_completions.rs b/crates/toolr/src/builtin_completions.rs index 6b6d434f..c51fa40e 100644 --- a/crates/toolr/src/builtin_completions.rs +++ b/crates/toolr/src/builtin_completions.rs @@ -282,10 +282,10 @@ mod tests { } #[test] - fn project_venv_offers_path_shell_sync_lock_add_remove() { + fn project_venv_offers_run_path_shell_sync_lock_add_remove() { let m = merged_empty_manifest(); let out = serve_completions(&m, &tokens(&["project", "venv", ""])); - for expected in ["path", "shell", "sync", "lock", "add", "remove"] { + for expected in ["run", "path", "shell", "sync", "lock", "add", "remove"] { assert!( out.contains(&expected.to_string()), "missing {expected} under project venv, got: {out:?}" diff --git a/crates/toolr/src/cli.rs b/crates/toolr/src/cli.rs index 61f35a53..a4567f88 100644 --- a/crates/toolr/src/cli.rs +++ b/crates/toolr/src/cli.rs @@ -316,6 +316,32 @@ pub fn build_command(manifest: &Manifest) -> Command { .disable_help_flag(true) .about("Spawn a subshell with the tools venv activated"), ) + .subcommand( + Command::new("run") + .disable_help_flag(true) + .about("Run a command inside the managed tools venv (auto-syncs first; use --no-sync in CI)") + .arg( + Arg::new("no-sync") + .long("no-sync") + .action(ArgAction::SetTrue) + .help("Do not sync first; run against the existing venv and error if it is missing or stale (CI-deterministic)"), + ) + .arg( + Arg::new("quiet") + .long("quiet") + .action(ArgAction::SetTrue) + .help("Pass --quiet to the auto-sync step (no effect with --no-sync)"), + ) + .arg( + Arg::new("command") + .value_name("CMD") + .required(true) + .num_args(1..) + .trailing_var_arg(true) + .allow_hyphen_values(true) + .help("Command to run in the venv, e.g. `pytest tools/`. toolr's own flags must come before the command (or a `--`)."), + ), + ) .subcommand( Command::new("sync") .disable_help_flag(true) diff --git a/crates/toolr/src/project.rs b/crates/toolr/src/project.rs index 54ff3d4c..948168e0 100644 --- a/crates/toolr/src/project.rs +++ b/crates/toolr/src/project.rs @@ -18,6 +18,7 @@ pub fn dispatch_project(matches: &ArgMatches) -> Result { Some(("venv", venv_m)) => match venv_m.subcommand() { Some(("path", _)) => venv_path(), Some(("shell", _)) => venv_shell(), + Some(("run", run_m)) => venv_run(run_m), Some(("sync", sync_m)) => venv_sync(sync_m), Some(("lock", lock_m)) => venv_lock(lock_m), Some(("add", add_m)) => venv_add(add_m), @@ -145,6 +146,9 @@ pub(crate) fn run_project_init( if !quiet { println!("toolr: skipping `uv sync` (--no-sync)"); println!("toolr: run `toolr project venv sync` when you are ready"); + println!( + "toolr: then run commands with `toolr project venv run -- ` (e.g. pytest tools/)" + ); } return Ok(ExitCode::SUCCESS); } @@ -172,6 +176,9 @@ pub(crate) fn run_project_init( println!( "toolr: toolr self completion install # optional, for tab completion" ); + println!( + "toolr: toolr project venv run -- pytest tools/ # run your tools' tests in the managed venv" + ); } Ok(ExitCode::SUCCESS) } @@ -278,6 +285,9 @@ fn venv_sync(matches: &ArgMatches) -> Result { resolved.venv_dir.display(), uv.version.0, uv.version.1, uv.version.2, ); + println!( + "toolr: run a command in it with `toolr project venv run -- ` (e.g. pytest tools/)" + ); } Ok(ExitCode::SUCCESS) } @@ -539,6 +549,89 @@ fn venv_shell() -> Result { Ok(ExitCode::from(status.code().unwrap_or(1) as u8)) } +fn venv_run(matches: &ArgMatches) -> Result { + let no_sync = matches.get_flag("no-sync"); + let quiet = matches.get_flag("quiet"); + let argv: Vec = matches + .get_many::("command") + .expect("clap marks command required") + .cloned() + .collect(); + + let cwd = std::env::current_dir()?; + + let resolved = if no_sync { + // Pure path: never touch the venv. Resolve, gate on freshness, + // validate, then run. Needs no uv / consent / network. + let repo_root = toolr_core::discovery::discover_project_root(&cwd) + .context("locating project root for the tools venv")?; + let resolved = toolr_core::venv::resolve_venv_path(&repo_root) + .context("resolving the tools venv path")?; + let tools = repo_root.join("tools"); + match toolr_core::venv::check_freshness(&resolved, &tools) { + toolr_core::venv::Freshness::Missing => anyhow::bail!( + "the tools venv hasn't been created yet — run `toolr project venv sync`" + ), + toolr_core::venv::Freshness::Stale => anyhow::bail!( + "the tools venv is out of date with tools/uv.lock — run `toolr project venv sync` (or drop --no-sync)" + ), + toolr_core::venv::Freshness::Fresh => {} + } + toolr_core::venv::validate_venv(&resolved.venv_dir, &resolved.python) + .map_err(|e| anyhow::anyhow!("validating the tools venv: {e}"))?; + resolved + } else { + // Default: freshness-gated auto-sync (same path as `venv sync`, + // and as `venv shell`), then run. `--quiet` forwards to uv's + // --quiet inside the sync. + let consent = toolr_core::uv::install::ConsentMode::from_env(); + let (resolved, _uv) = toolr_core::project::ensure_venv_ready( + &cwd, + consent, + toolr_core::project::EnsureOpts::default().with_quiet(quiet), + )?; + resolved + }; + + run_command_in_venv(&resolved.venv_dir, &argv) +} + +/// Activate `venv_dir` (VIRTUAL_ENV + TOOLR_VENV + PATH prepend) and run +/// `argv` in it, inheriting stdio and passing the child's exit code +/// through. A not-found spawn error becomes an actionable nudge (exit +/// 127) instead of a raw OS error. No command echo — the child owns +/// stdout/stderr (parity with `uv run`). +fn run_command_in_venv(venv_dir: &Path, argv: &[String]) -> Result { + use std::process::Command; + + let bin_dir = venv_bin_dir(venv_dir); + let prepended_path = prepend_to_path(&bin_dir, std::env::var_os("PATH").as_deref())?; + let (program, rest) = argv + .split_first() + .expect("clap enforces at least one command token"); + + let result = Command::new(program) // nosemgrep: rust.actix.command-injection.rust-actix-command-injection.rust-actix-command-injection + .args(rest) + .env("VIRTUAL_ENV", venv_dir) + .env("TOOLR_VENV", venv_dir) + .env("PATH", &prepended_path) + .status(); + + match result { + Ok(status) => Ok(ExitCode::from(status.code().unwrap_or(1) as u8)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("toolr: couldn't find `{program}` in the tools venv."); + eprintln!( + "hint: did you forget to add it to tools/pyproject.toml (then `toolr project venv sync`)?" + ); + Ok(ExitCode::from(127)) + } + Err(e) => { + Err(anyhow::Error::new(e).context(format!("spawning `{program}` in the tools venv"))) + } + } +} + /// Resolve the shell binary to spawn for `toolr project venv shell`. /// Honours `$SHELL`, falling back to a per-OS default. Extracted as a /// pure helper so the fallback arms are unit-testable. diff --git a/crates/toolr/tests/project_venv_run.rs b/crates/toolr/tests/project_venv_run.rs new file mode 100644 index 00000000..d0b1700c --- /dev/null +++ b/crates/toolr/tests/project_venv_run.rs @@ -0,0 +1,370 @@ +use assert_cmd::Command; +use tempfile::TempDir; + +/// A tools/pyproject.toml pinned to an in-tree venv so the resolved venv +/// path is deterministically `tools/.venv` (no cache-key hashing). +const IN_TREE_PYPROJECT: &str = + "[project]\nname=\"x\"\nversion=\"0\"\n\n[tool.toolr]\nvenv-location = \"in-tree\"\n"; + +fn write_tools(repo: &std::path::Path, pyproject: &str) { + let tools = repo.join("tools"); + std::fs::create_dir_all(&tools).unwrap(); + std::fs::write(tools.join("pyproject.toml"), pyproject).unwrap(); +} + +#[test] +fn no_sync_errors_when_venv_missing() { + let tmp = TempDir::new().unwrap(); + write_tools(tmp.path(), IN_TREE_PYPROJECT); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args([ + "project", + "venv", + "run", + "--no-sync", + "--", + "python", + "-c", + "pass", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("hasn't been created"), "stderr: {stderr}"); +} + +#[test] +fn requires_a_command() { + let tmp = TempDir::new().unwrap(); + write_tools(tmp.path(), IN_TREE_PYPROJECT); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run"]) + .output() + .unwrap(); + // clap rejects the missing required positional before dispatch. + assert!(!output.status.success()); +} + +#[test] +fn help_mentions_no_sync() { + let output = Command::cargo_bin("toolr") + .unwrap() + .args(["project", "venv", "run", "--help"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--no-sync"), "help was: {stdout}"); +} + +/// Build an in-tree fake venv at `tools/.venv` good enough for +/// `validate_venv`: a runnable `bin/python` (body supplied) plus a fake +/// installed `toolr` package. Returns the venv dir. Unix-only: writes a +/// `#!/bin/sh` interpreter and 0o755 perms. +#[cfg(unix)] +fn fake_in_tree_venv(repo: &std::path::Path, python_body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + write_tools(repo, IN_TREE_PYPROJECT); + let venv = repo.join("tools").join(".venv"); + let bin = venv.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let python = bin.join("python"); + std::fs::write(&python, python_body).unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + let site = venv + .join("lib") + .join("python3.13") + .join("site-packages") + .join("toolr"); + std::fs::create_dir_all(&site).unwrap(); + std::fs::write(site.join("__init__.py"), b"").unwrap(); + venv +} + +/// Build an in-tree fake venv at `tools/.venv` good enough for +/// `validate_venv` on any platform: an interpreter placeholder (an empty +/// file — never executed by the tests that use this builder, since they +/// only exercise command-not-found resolution) at the OS-correct path +/// (`bin/python` on Unix, `Scripts\python.exe` on Windows), plus a fake +/// installed `toolr` package at the OS-correct site-packages layout +/// (`lib/pythonX.Y/site-packages/toolr/` on Unix, `Lib/site-packages/toolr/` +/// on Windows — see `toolr_core::venv::validate::candidate_site_packages`), +/// unless `with_toolr_pkg` is false (used to exercise the validation-failure +/// path). Returns the venv dir. +fn fake_in_tree_venv_cross_platform( + repo: &std::path::Path, + with_toolr_pkg: bool, +) -> std::path::PathBuf { + write_tools(repo, IN_TREE_PYPROJECT); + let venv = repo.join("tools").join(".venv"); + + let python = if cfg!(windows) { + venv.join("Scripts").join("python.exe") + } else { + venv.join("bin").join("python") + }; + std::fs::create_dir_all(python.parent().unwrap()).unwrap(); + std::fs::write(&python, b"").unwrap(); + + if with_toolr_pkg { + let site = if cfg!(windows) { + venv.join("Lib").join("site-packages").join("toolr") + } else { + venv.join("lib") + .join("python3.13") + .join("site-packages") + .join("toolr") + }; + std::fs::create_dir_all(&site).unwrap(); + std::fs::write(site.join("__init__.py"), b"").unwrap(); + } + + venv +} + +/// Mark the venv Fresh: uv.lock first, then the sync stamp (newer mtime). +fn mark_fresh(repo: &std::path::Path, venv: &std::path::Path) { + std::fs::write(repo.join("tools").join("uv.lock"), b"lock").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(venv.join(".toolr-sync-stamp"), b"").unwrap(); +} + +#[cfg(unix)] +#[test] +fn no_sync_errors_when_stale() { + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv(tmp.path(), "#!/bin/sh\nexit 0\n"); + // Stamp older than uv.lock → Stale. + std::fs::write(venv.join(".toolr-sync-stamp"), b"").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(tmp.path().join("tools").join("uv.lock"), b"lock").unwrap(); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "python"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("out of date")); +} + +#[cfg(unix)] +#[test] +fn no_sync_fresh_passes_exit_code_through() { + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv(tmp.path(), "#!/bin/sh\nexit 3\n"); + mark_fresh(tmp.path(), &venv); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "python"]) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(3), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +#[test] +fn no_sync_passes_args_verbatim() { + let tmp = TempDir::new().unwrap(); + let arglog = tmp.path().join("arglog"); + let body = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\nexit 0\n", + arglog.display() + ); + let venv = fake_in_tree_venv(tmp.path(), &body); + mark_fresh(tmp.path(), &venv); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args([ + "project", + "venv", + "run", + "--no-sync", + "--", + "python", + "-k", + "foo", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let logged = std::fs::read_to_string(&arglog).unwrap(); + assert!(logged.lines().any(|l| l == "-k"), "argv: {logged}"); + assert!(logged.lines().any(|l| l == "foo"), "argv: {logged}"); +} + +#[test] +fn not_found_command_gets_nudge() { + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv_cross_platform(tmp.path(), true); + mark_fresh(tmp.path(), &venv); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args([ + "project", + "venv", + "run", + "--no-sync", + "--", + "toolr-definitely-absent-xyz", + ]) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(127), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("couldn't find `toolr-definitely-absent-xyz`"), + "stderr: {stderr}" + ); + assert!(stderr.contains("tools/pyproject.toml"), "stderr: {stderr}"); +} + +/// A fresh venv that passes the freshness gate but fails `validate_venv` +/// (the interpreter placeholder exists, but no `toolr` package is +/// installed) must surface the "validating the tools venv" error, not run +/// anything. Covers the `validate_venv(...).map_err(...)` arm on the +/// `--no-sync` path. Cross-platform: never executes the interpreter. +#[test] +fn no_sync_errors_when_venv_is_incomplete() { + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv_cross_platform(tmp.path(), /* with_toolr_pkg */ false); + mark_fresh(tmp.path(), &venv); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "python"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("validating the tools venv"), + "expected a validation error, got stderr: {stderr}" + ); +} + +/// A command that resolves on the venv PATH but cannot be executed (a +/// non-executable file) fails to spawn with `PermissionDenied`, not +/// `NotFound`. It must surface a real spawn error — NOT the +/// command-not-found nudge, which is reserved for `NotFound`. Covers the +/// generic `Err(e)` arm of `run_command_in_venv`. Unix-only: relies on the +/// execute permission bit. +#[cfg(unix)] +#[test] +fn non_executable_command_surfaces_spawn_error_not_nudge() { + use std::os::unix::fs::PermissionsExt; + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv(tmp.path(), "#!/bin/sh\nexit 0\n"); + mark_fresh(tmp.path(), &venv); + // A file on the venv's bin PATH that exists but is not executable. + let blocked = venv.join("bin").join("toolr-blocked-tool-xyz"); + std::fs::write(&blocked, "not executable\n").unwrap(); + std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args([ + "project", + "venv", + "run", + "--no-sync", + "--", + "toolr-blocked-tool-xyz", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("spawning `toolr-blocked-tool-xyz`"), + "expected a spawn error, got stderr: {stderr}" + ); + assert!( + !stderr.contains("couldn't find"), + "a permission error must not be reported as command-not-found: {stderr}" + ); +} + +#[test] +fn project_init_next_steps_mention_venv_run() { + let tmp = TempDir::new().unwrap(); + // --no-sync keeps this offline and fast; we only assert on the + // scaffolding next-steps output, not on a real sync. + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "init", "--no-sync"]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("project venv run"), + "next-steps should advertise `venv run`, got: {stdout}" + ); +} + +/// This is the cross-platform vehicle for coverage that can't be faked +/// without a real synced venv: real `Scripts\*.exe` (Windows) / `bin/*` +/// (Unix) bare-command PATHEXT/PATH resolution, and exit-code passthrough +/// from a real interpreter. Run with `--ignored` on the Windows CI leg to +/// cover the Windows-specific behavior the other (Unix-only) tests in this +/// file can't reach with fake venvs. +#[test] +#[ignore = "network-touching: requires uv to be available or installable"] +fn runs_in_a_real_synced_venv() { + let tmp = TempDir::new().unwrap(); + write_tools( + tmp.path(), + "[project]\nname=\"toolr-tools\"\nversion=\"0\"\nrequires-python=\">=3.11\"\ndependencies=[\"toolr\"]\n\n[tool.toolr]\nvenv-location = \"in-tree\"\n", + ); + // Default path auto-syncs, then runs `python` from the venv. + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args([ + "project", + "venv", + "run", + "--", + "python", + "-c", + "print('ran-in-venv')", + ]) + .env("TOOLR_AUTO_INSTALL_UV", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("ran-in-venv")); +} diff --git a/docs/cli-files/project-venv-run-help.txt b/docs/cli-files/project-venv-run-help.txt new file mode 100644 index 00000000..6ef25764 --- /dev/null +++ b/docs/cli-files/project-venv-run-help.txt @@ -0,0 +1,16 @@ +Run a command inside the managed tools venv (auto-syncs first; use --no-sync in CI) + +Usage: toolr project venv run [OPTIONS] + +Arguments: + Command to run in the venv, e.g. pytest tools/. toolr's own flags must come before the +         command (or a --). + +Options: +     --no-sync +          Do not sync first; run against the existing venv and error if it is missing or stale +          (CI-deterministic) +     --quiet +          Pass --quiet to the auto-sync step (no effect with --no-sync) + -h, --help +          Print help diff --git a/docs/cli.md b/docs/cli.md index eb8e8d6e..782ad202 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -186,6 +186,32 @@ toolr project venv shell --help --8<-- "docs/cli-files/project-venv-shell-help.txt" ``` +### `toolr project venv run` {#project-venv-run} + +Run a command inside the managed tools venv. By default it syncs the venv +first (freshness-gated, exactly like [`venv sync`](#project-venv-sync) and +`venv shell`), then runs the command with `$VIRTUAL_ENV`, `$TOOLR_VENV`, and +`$PATH` set so entry points such as `pytest` resolve from the venv. The child's +stdout, stderr, and exit code pass straight through. + +This is the one-liner for running a command-package's tests: + +```sh +toolr project venv run -- pytest tools/ +``` + +`--no-sync` never touches the venv — it errors if the venv is missing or stale +instead of syncing. Use it in CI once the venv is known-synced, for +deterministic runs. toolr's own flags must come before the command (or a `--`). + +```sh +toolr project venv run --help +``` + +```text +--8<-- "docs/cli-files/project-venv-run-help.txt" +``` + ### `toolr project manifest rebuild` {#project-manifest-rebuild} Regenerate the static + dynamic manifest in place. Equivalent to what diff --git a/skills/toolr-ci-setup/SKILL.md b/skills/toolr-ci-setup/SKILL.md index cbb5bdcb..ea0b6bb8 100644 --- a/skills/toolr-ci-setup/SKILL.md +++ b/skills/toolr-ci-setup/SKILL.md @@ -171,6 +171,29 @@ in the wheel — see [`toolr-command-packaging`](https://github.com/s0undt3ch/toolr/tree/main/skills/toolr-command-packaging) — this skill only owns the `--check` gate side. +## Recipe 3 — Run a command-package's tests in CI + +For deterministic CI test runs, sync the venv explicitly, then run tests +against that already-synced venv rather than letting the run step re-sync: + +```yaml +name: toolr +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: s0undt3ch/ToolR@ # v0.20.0 + - run: toolr project venv sync + - run: toolr project venv run --no-sync -- pytest tools/ +``` + +`--no-sync` makes the run step fail fast (instead of silently re-syncing) if +the venv is missing or stale — the deterministic behaviour you want in CI, as +opposed to the auto-sync-by-default `toolr project venv run` recipe used for +local iteration. + ## Inputs and outputs at a glance The full input/output surface (defaults, descriptions, what each diff --git a/skills/toolr-command-authoring/SKILL.md b/skills/toolr-command-authoring/SKILL.md index 2127f386..5ec38a69 100644 --- a/skills/toolr-command-authoring/SKILL.md +++ b/skills/toolr-command-authoring/SKILL.md @@ -187,6 +187,17 @@ make its registration a top-level, statically-visible declaration. `tools/*.py`. Fix the source; the manifest auto-rebuilds on the next dispatch. +## Running your commands' tests + +Run your commands' tests in the managed venv with: + +```sh +toolr project venv run -- pytest tools/ +``` + +This syncs the venv if stale, then runs `pytest` from it. No need to hand-build +the `"$(toolr project venv path)/bin/python" -m pytest …` invocation. + ## Packaging is a different problem If the user wants to **ship** an existing set of toolr commands as a diff --git a/specs/2026-05-27-toolr-ci-setup-plan.md b/specs/archive/2026/2026-05-27-toolr-ci-setup-plan.md similarity index 100% rename from specs/2026-05-27-toolr-ci-setup-plan.md rename to specs/archive/2026/2026-05-27-toolr-ci-setup-plan.md diff --git a/specs/2026-05-27-toolr-ci-setup-skill-design.md b/specs/archive/2026/2026-05-27-toolr-ci-setup-skill-design.md similarity index 100% rename from specs/2026-05-27-toolr-ci-setup-skill-design.md rename to specs/archive/2026/2026-05-27-toolr-ci-setup-skill-design.md diff --git a/specs/2026-06-10-remove-editable-install-design.md b/specs/archive/2026/2026-06-10-remove-editable-install-design.md similarity index 100% rename from specs/2026-06-10-remove-editable-install-design.md rename to specs/archive/2026/2026-06-10-remove-editable-install-design.md diff --git a/specs/2026-06-10-remove-editable-install-plan.md b/specs/archive/2026/2026-06-10-remove-editable-install-plan.md similarity index 100% rename from specs/2026-06-10-remove-editable-install-plan.md rename to specs/archive/2026/2026-06-10-remove-editable-install-plan.md diff --git a/specs/2026-06-10-runner-path-hygiene-design.md b/specs/archive/2026/2026-06-10-runner-path-hygiene-design.md similarity index 100% rename from specs/2026-06-10-runner-path-hygiene-design.md rename to specs/archive/2026/2026-06-10-runner-path-hygiene-design.md diff --git a/specs/2026-06-10-runner-path-hygiene-plan.md b/specs/archive/2026/2026-06-10-runner-path-hygiene-plan.md similarity index 100% rename from specs/2026-06-10-runner-path-hygiene-plan.md rename to specs/archive/2026/2026-06-10-runner-path-hygiene-plan.md diff --git a/specs/2026-06-10-static-only-manifest-design.md b/specs/archive/2026/2026-06-10-static-only-manifest-design.md similarity index 100% rename from specs/2026-06-10-static-only-manifest-design.md rename to specs/archive/2026/2026-06-10-static-only-manifest-design.md diff --git a/specs/2026-06-10-static-only-manifest-plan.md b/specs/archive/2026/2026-06-10-static-only-manifest-plan.md similarity index 100% rename from specs/2026-06-10-static-only-manifest-plan.md rename to specs/archive/2026/2026-06-10-static-only-manifest-plan.md diff --git a/specs/archive/2026/2026-07-13-venv-run-design.md b/specs/archive/2026/2026-07-13-venv-run-design.md new file mode 100644 index 00000000..7919dd47 --- /dev/null +++ b/specs/archive/2026/2026-07-13-venv-run-design.md @@ -0,0 +1,232 @@ +# `toolr project venv run` — run a command in the managed venv + +- **Status:** design +- **Date:** 2026-07-13 +- **Issue:** [s0undt3ch/ToolR#373](https://github.com/s0undt3ch/ToolR/issues/373) + +## Problem + +Running a command-package's tests (or any tool) in the toolr-managed venv is +tribal knowledge. The working invocation is: + +```sh +"$(toolr project venv path)/bin/python" -m pytest tools/ +``` + +A contributor has to know that incantation, and it only works if `pytest` was +added to `tools/pyproject.toml`. The backend/app venv usually lacks `toolr-py`, +so `pytest tools/` there fails at import. CI reimplements the same steps (build +a venv, install the tools deps, run pytest by explicit path), so local and CI +drift. + +Issue #373 asks for a first-class, discoverable way to run a package's tests in +the right environment. + +## Decision: a general runner, not a test command + +The issue frames this as `toolr test` — a pytest convenience wrapper — and then +spends most of its length worrying about pytest lock-in, runner-agnosticism, and +"no magic." That anxiety is the signal that the *specific* shape (`test`) is +fighting the *general* need: **run one command in the managed venv**. + +toolr already ships the two halves of a general primitive: `project venv path` +(locate it) and `project venv shell` (drop into it interactively). What is +missing is the non-interactive middle. We add exactly that: + +```text +toolr project venv run [OPTIONS] -- [ARGS...] +``` + +With this, "test" is just one call of it, CI uses it verbatim, and there is +**zero runner lock-in because there is no runner concept at all**. The +documented test one-liner becomes: + +```sh +toolr project venv run -- pytest tools/ +``` + +### Why under `project venv`, not a top-level `toolr run` + +`project` is a reserved built-in group (like `self`). `project venv run` can +never collide with a user's command package. A top-level `toolr run` would sit +in the same namespace as user-defined commands and would either shadow, or be +shadowed by, a user's own `run` command/group. So there is **no top-level +`toolr run` alias** — the runner lives at `toolr project venv run` only. + +## Command surface + +```text +toolr project venv run [--no-sync] [--quiet] -- [ARGS...] +``` + +- Runs ` [ARGS...]` inside the managed tools venv. +- `--` is the documented separator so pass-through flags (`-k foo`) are + unambiguous. clap uses `trailing_var_arg(true)` + `allow_hyphen_values(true)` + so `toolr project venv run pytest -k foo` also works without the explicit + `--`. +- **At least one argument (the command) is required.** Unlike the issue's + `toolr test`, there is no default target: a general runner has no sensible + default. `venv run` with no command is a clap usage error. +- **toolr's own flags must precede the command (or `--`).** Because of + `trailing_var_arg`, once the first positional is seen everything after it is + captured for the child — so `toolr project venv run pytest --no-sync` sends + `--no-sync` to pytest, not to toolr. The `--help` text states this explicitly. +- **`--quiet`** forwards to the auto-sync step (parity with `uv run --quiet` and + `toolr project venv sync --quiet`); see the output section. `--quiet` only — + no short `-q`, since nearly every runnable tool defines its own `-q` + (pytest included) and reserving a toolr `-q` here would be a cognitive clash. + +## Execution semantics + +Mirror `venv shell`, but non-interactive with a supplied argv: + +1. Make the venv ready (see the sync section) and obtain the `ResolvedVenv`. +2. **Validate the venv** (`validate_venv`) before spawning, so a corrupt or + incomplete venv surfaces a real error instead of the misleading + "couldn't find ``" nudge below. (The default sync path already validates + inside `ensure_venv_ready`; the `--no-sync` path validates explicitly.) +3. Set `VIRTUAL_ENV` and `TOOLR_VENV` to the venv dir; prepend `/bin` + (`Scripts` on Windows) to `PATH`; `env_remove` any conflicting outer + `VIRTUAL_ENV`. Reuses the existing `venv_bin_dir` / `prepend_to_path` helpers + in `project.rs`. +4. **Preserve the caller's current working directory** — `venv run` does *not* + `cd` to the project root (same as `venv shell` and `uv run`). Paths the user + types are relative to where they stand. +5. Spawn the child inheriting stdio and **pass its exit code straight through**. + +Because Rust's `std::process::Command` resolves a bare program name against the +child's `PATH`, `pytest`, `ruff`, and `python -m pytest` all resolve from the +venv automatically. On Windows this relies on `CreateProcess` + `PATHEXT` +appending `.exe` to a bare command name (`Scripts\pytest.exe`) — see the testing +section. + +## Sync / freshness policy — auto-sync by default, `--no-sync` for CI + +The default behavior mirrors the two commands users will pattern-match against: +`uv run` (which toolr wraps) and the sibling `toolr project venv shell` — **both +auto-sync a stale environment before running.** Matching them is the +least-surprising, internally-consistent choice, and it is *self-healing*: the +mtime-based freshness check (`check_freshness`, comparing the venv marker mtime +against `tools/uv.lock`) is a heuristic that can false-positive after a clone or +a CI cache restore scrambles mtimes; an auto-sync default simply re-syncs (a fast +no-op when truly fresh) instead of hard-failing on the heuristic. + +**Default (no flag):** call `ensure_venv_ready` — freshness-gated `uv sync`, +same path as `toolr project venv sync`. A fresh venv is a fast no-op; a stale or +missing one is synced, then the command runs. This is the only path that needs +`uv` + the consent machinery, exactly as `venv shell` already does. + +**`--no-sync`:** the CI-deterministic path. Never touch the venv — no `uv`, no +consent, no network. Resolve the venv (`resolve_venv_path`) and gate on the +read-only `check_freshness()`: + +- `Missing` → exit non-zero: + *"the tools venv hasn't been created yet — run `toolr project venv sync`"* +- `Stale` → exit non-zero: + *"the tools venv is out of date with tools/uv.lock — run `toolr project venv sync` (or drop --no-sync)"* +- `Fresh` → proceed. + +Under `--no-sync` the caller has explicitly opted into strictness, so `Stale` is +fatal (the false-positive risk is theirs to own, and the fix is one command +away). There is deliberately **no `--sync` flag** — auto-sync *is* the default, +so a separate opt-in would be redundant, and it would have re-coupled the pure +runner to the uv/consent machinery it otherwise avoids. + +## Command-not-found error + +The issue asks for a clear error instead of a bare `ModuleNotFoundError`. When +the spawn fails with a not-found OS error (argv[0] isn't on the venv `PATH`), +catch it and emit an honest message that states the fact and offers the *likely* +cause as a question — we do not assert a cause we did not verify: + +> toolr: couldn't find `pytest` in the tools venv. +> hint: did you forget to add it to tools/pyproject.toml (then `toolr project venv sync`)? + +**Scope:** this nudge fires only when **argv[0] itself** is not found (`pytest`, +`ruff`, a bare tool). For `python -m somemodule` where the *module* is missing, +the `python` executable exists, so Python emits its own `ModuleNotFoundError` +and we pass that through unchanged — intercepting it would require parsing child +stderr, which we deliberately do not do. + +## Output + +**`venv run` does not echo the command it runs.** `uv run`, `poetry run`, and +`cargo run` all run the child without printing its argv first; matching them is +the least-surprising choice and keeps CI logs clean. The child owns stdout and +stderr; toolr adds nothing to them on the happy path. (The venv is discoverable +via `toolr project venv path` if anyone needs to know exactly where the command +resolved.) + +The only toolr-originated output is: + +- The **auto-sync step's** own progress on the default path — this is toolr/uv + output, on stderr, exactly as `venv sync` / `uv run` already emit it. + **`--quiet`** forwards to that sync to suppress it (parity with + `uv run --quiet`). Under `--no-sync` there is no sync, so `--quiet` is a + harmless no-op. +- Error messages (venv `Missing`/`Stale` under `--no-sync`, command-not-found), + on stderr. + +## Files touched + +- `crates/toolr/src/cli.rs` — declare the `run` subcommand under `venv` + (`trailing_var_arg`, `allow_hyphen_values`, `--no-sync`, `--quiet`; + help text notes the flags-before-command ordering rule). +- `crates/toolr/src/project.rs` — `venv_run()` handler + dispatch arm; factor + the spawn/activation into a testable helper alongside the existing shell + helpers. Also **wire discoverability** (fix D): add the + `toolr project venv run -- pytest tools/` one-liner to `run_project_init`'s + next-steps output (currently lists `toolr example hello`, `project.rs:170`) + and to the `venv sync` success hint, so the runner is findable without a + top-level alias. +- `crates/toolr/tests/project_venv_run.rs` — new `assert_cmd` integration test: + exit-code passthrough, command-not-found message, arg pass-through via `--`, + no command echo on the happy path (child stdout/stderr only), and the + `--no-sync` gate (`Missing` → error, `Stale` → error, `Fresh` → runs). The + default auto-sync path (and `--quiet` forwarding to it) is covered where + `end_to_end_sync.rs`-style fixtures already exercise a real `uv` sync. +- `crates/toolr/src/builtin_completions.rs` — add the entry (derived from + `cli::build_command`). +- Docs: `docs/cli.md` + regenerated `docs/cli-files/project-venv-run-help.txt` + (via the doc-snippets hook), plus a short "running tests / tools in the + managed venv" note that replaces the `venv path` one-liner guidance. +- `UNRELEASED.md` — release note. + +## AI skill updates + +Two layers, both required so the shipped skills teach the new one-liner: + +- **Auto-generated refs:** `cargo xtask build-skill-refs` (mechanical; gated by + `--check` in `mise run test` and CI). +- **Hand-written prose:** + - `skills/toolr-command-authoring/SKILL.md` — the "how do I run / test my + commands" workflow moves from the `"$(toolr project venv path)/bin/python"` + incantation to `toolr project venv run -- pytest tools/`. + - `skills/toolr-ci-setup/SKILL.md` — the CI recipe for running a package's + tests in the managed venv uses `toolr project venv run` instead of the + hand-rolled venv-build-then-pytest steps. + - Update the matching `tests/triggers.yaml` where a trigger phrase references + the old invocation. + +## Non-goals + +- No `toolr test` command and no top-level `toolr run` alias (see rationale + above). +- No `[tool.toolr]` test configuration — there is no runner concept to + configure. +- `venv run` does not parse or rewrite child output (beyond the argv[0] + not-found case). + +## Testing / verification scope + +Touches Rust and skills → full umbrella `mise run test` (skill-refs drift gate + +`cargo test --workspace` + pytest), plus `prek run --all-files` and +`mkdocs build --strict` for the docs. Regenerate doc snippets and skill refs; +do not hand-edit them. + +**Windows (fix H):** bare-command resolution (`Scripts\pytest.exe` via +`PATHEXT`) is new surface. The `project_venv_run.rs` command-not-found and +exit-code-passthrough cases must run in the Windows leg of the integration +matrix (as `cli_smoke.rs` already does), not just Unix — assert against a +command that exists cross-platform (e.g. `python -c ...`) plus a +guaranteed-absent one for the not-found nudge. diff --git a/specs/archive/2026/2026-07-13-venv-run-plan.md b/specs/archive/2026/2026-07-13-venv-run-plan.md new file mode 100644 index 00000000..be483acf --- /dev/null +++ b/specs/archive/2026/2026-07-13-venv-run-plan.md @@ -0,0 +1,705 @@ +# `toolr project venv run` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `toolr project venv run [--no-sync] [--quiet] -- [ARGS...]`, a first-class way to run any command inside the managed tools venv. + +**Architecture:** A new leaf subcommand under the existing `project venv` group. Default behavior auto-syncs the venv (reusing `ensure_venv_ready`, exactly like `venv shell`) then activates it (`VIRTUAL_ENV` + `TOOLR_VENV` + `PATH` prepend, reusing the existing `venv_bin_dir`/`prepend_to_path` helpers) and execs the child, passing its exit code through. `--no-sync` is a pure, uv-free path that resolves + freshness-gates + validates and errors on a missing/stale venv. No new core crate code — all needed primitives (`resolve_venv_path`, `check_freshness`/`Freshness`, `validate_venv`, `ensure_venv_ready`) already exist and are re-exported. + +**Tech Stack:** Rust, clap 4, `anyhow`, `assert_cmd` + `tempfile` for integration tests. Design: `specs/2026-07-13-venv-run-design.md`. + +## Global Constraints + +- **No command echo on the happy path** — the child owns stdout/stderr; toolr prints nothing extra when it runs (matches `uv run`/`poetry run`/`cargo run`). Copied verbatim from the design's Output section. +- **`--quiet` only, no short `-q`** — it forwards to the auto-sync step; no-op under `--no-sync`. +- **toolr's own flags precede the command** — `trailing_var_arg` captures everything after the first positional for the child. +- **Conventional Commits** on every commit (`feat(cli): …`, `docs(cli): …`, `test(cli): …`). +- **No `Co-Authored-By` footer** on any commit (repo policy). +- **Stage files explicitly by path** — never `git add -A` (an untracked local `audit/` dir must never be staged). Verify with `git diff --cached --name-only` before each commit. +- **Never write an employer name or absolute `/Users/...` paths** into source/tests/docs/specs. Use `~` and generic names. +- **Regenerate, don't hand-edit** doc snippets (`.pre-commit-hooks/regen-doc-snippets.py`) and skill refs (`cargo xtask build-skill-refs`). +- **Verification scope:** touches Rust + skills → full umbrella `mise run test`, plus `prek run --all-files` and `mkdocs build --strict` for docs. + +--- + +## File Structure + +- `crates/toolr/src/cli.rs` — declare the `run` subcommand under `venv` (modify, insert after the `shell` subcommand near line 318). +- `crates/toolr/src/project.rs` — `venv_run()` handler + `run_command_in_venv()` helper + dispatch arm; discoverability strings in `run_project_init` and `venv_sync` (modify). +- `crates/toolr/tests/project_venv_run.rs` — new integration test file. +- `crates/toolr/src/builtin_completions.rs` — update the enumeration test (modify test only; entries are auto-derived). +- `.pre-commit-hooks/regen-doc-snippets.py` — add a `Snippet` for the new `--help` (modify). +- `docs/cli.md` — add a `### toolr project venv run` section (modify). +- `docs/cli-files/project-venv-run-help.txt` — generated by the snippet hook (create-via-regen). +- `skills/toolr-command-authoring/SKILL.md`, `skills/toolr-ci-setup/SKILL.md` — prose updates (modify). +- `UNRELEASED.md` — release note (modify). +- `specs/2026-07-13-venv-run-{design,plan}.md` — `git mv` to `specs/archive/2026/` as the final step (move). + +--- + +## Task 1: The `venv run` command (CLI + handler + dispatch) + +**Files:** + +- Modify: `crates/toolr/src/cli.rs` (insert `run` subcommand after `shell`, ~line 318) +- Modify: `crates/toolr/src/project.rs` (dispatch arm in `dispatch_project`; new `venv_run` + `run_command_in_venv`) +- Test: `crates/toolr/tests/project_venv_run.rs` (new) + +**Interfaces:** + +- Consumes (all already public in `toolr_core`): + - `toolr_core::discovery::discover_project_root(cwd: &Path) -> Result` + - `toolr_core::venv::resolve_venv_path(repo_root: &Path) -> Result` where `ResolvedVenv { venv_dir: PathBuf, python: PathBuf, .. }` + - `toolr_core::venv::check_freshness(&ResolvedVenv, tools_dir: &Path) -> Freshness` (`Missing | Stale | Fresh`) + - `toolr_core::venv::validate_venv(venv_dir: &Path, python: &Path) -> Result` + - `toolr_core::project::ensure_venv_ready(cwd, ConsentMode, EnsureOpts) -> Result<(ResolvedVenv, UvBinary)>` + - existing private helpers in `project.rs`: `venv_bin_dir(&Path) -> PathBuf`, `prepend_to_path(&Path, Option<&OsStr>) -> Result` +- Produces: `fn venv_run(matches: &ArgMatches) -> Result` (dispatched from the `venv` match); `fn run_command_in_venv(venv_dir: &Path, argv: &[String]) -> Result`. +- [ ] **Step 1: Write the always-on failing integration tests** + +Create `crates/toolr/tests/project_venv_run.rs`: + +```rust +use assert_cmd::Command; +use tempfile::TempDir; + +/// A tools/pyproject.toml pinned to an in-tree venv so the resolved venv +/// path is deterministically `tools/.venv` (no cache-key hashing). +const IN_TREE_PYPROJECT: &str = + "[project]\nname=\"x\"\nversion=\"0\"\n\n[tool.toolr]\nvenv-location = \"in-tree\"\n"; + +fn write_tools(repo: &std::path::Path, pyproject: &str) { + let tools = repo.join("tools"); + std::fs::create_dir_all(&tools).unwrap(); + std::fs::write(tools.join("pyproject.toml"), pyproject).unwrap(); +} + +#[test] +fn no_sync_errors_when_venv_missing() { + let tmp = TempDir::new().unwrap(); + write_tools(tmp.path(), IN_TREE_PYPROJECT); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "python", "-c", "pass"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("hasn't been created"), "stderr: {stderr}"); +} + +#[test] +fn requires_a_command() { + let tmp = TempDir::new().unwrap(); + write_tools(tmp.path(), IN_TREE_PYPROJECT); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run"]) + .output() + .unwrap(); + // clap rejects the missing required positional before dispatch. + assert!(!output.status.success()); +} + +#[test] +fn help_mentions_no_sync() { + let output = Command::cargo_bin("toolr") + .unwrap() + .args(["project", "venv", "run", "--help"]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--no-sync"), "help was: {stdout}"); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p toolr --test project_venv_run` +Expected: FAIL — `no_sync_errors_when_venv_missing` and `help_mentions_no_sync` fail because the `run` subcommand doesn't exist yet (clap errors "unrecognized subcommand"). `requires_a_command` may pass accidentally (unrecognized subcommand also fails) — that's fine; it becomes meaningful once `run` exists. + +- [ ] **Step 3: Declare the `run` subcommand in the CLI** + +In `crates/toolr/src/cli.rs`, immediately after the `shell` subcommand block (the one ending `.about("Spawn a subshell with the tools venv activated"),` around line 318), insert: + +```rust +.subcommand( + Command::new("run") + .disable_help_flag(true) + .about("Run a command inside the managed tools venv (auto-syncs first; use --no-sync in CI)") + .arg( + Arg::new("no-sync") + .long("no-sync") + .action(ArgAction::SetTrue) + .help("Do not sync first; run against the existing venv and error if it is missing or stale (CI-deterministic)"), + ) + .arg( + Arg::new("quiet") + .long("quiet") + .action(ArgAction::SetTrue) + .help("Pass --quiet to the auto-sync step (no effect with --no-sync)"), + ) + .arg( + Arg::new("command") + .value_name("CMD") + .required(true) + .num_args(1..) + .trailing_var_arg(true) + .allow_hyphen_values(true) + .help("Command to run in the venv, e.g. `pytest tools/`. toolr's own flags must come before the command (or a `--`)."), + ), +) +``` + +- [ ] **Step 4: Add the dispatch arm and handler in `project.rs`** + +In `dispatch_project`, inside the `Some(("venv", venv_m)) => match venv_m.subcommand()` block, add this arm alongside the existing ones (e.g. after the `shell` arm): + +```rust +Some(("run", run_m)) => venv_run(run_m), +``` + +Then add these two functions to `crates/toolr/src/project.rs` (near `venv_shell`, so they can share `venv_bin_dir` / `prepend_to_path`): + +```rust +fn venv_run(matches: &ArgMatches) -> Result { + let no_sync = matches.get_flag("no-sync"); + let quiet = matches.get_flag("quiet"); + let argv: Vec = matches + .get_many::("command") + .expect("clap marks command required") + .cloned() + .collect(); + + let cwd = std::env::current_dir()?; + + let resolved = if no_sync { + // Pure path: never touch the venv. Resolve, gate on freshness, + // validate, then run. Needs no uv / consent / network. + let repo_root = toolr_core::discovery::discover_project_root(&cwd) + .context("locating project root for the tools venv")?; + let resolved = toolr_core::venv::resolve_venv_path(&repo_root) + .context("resolving the tools venv path")?; + let tools = repo_root.join("tools"); + match toolr_core::venv::check_freshness(&resolved, &tools) { + toolr_core::venv::Freshness::Missing => anyhow::bail!( + "the tools venv hasn't been created yet — run `toolr project venv sync`" + ), + toolr_core::venv::Freshness::Stale => anyhow::bail!( + "the tools venv is out of date with tools/uv.lock — run `toolr project venv sync` (or drop --no-sync)" + ), + toolr_core::venv::Freshness::Fresh => {} + } + toolr_core::venv::validate_venv(&resolved.venv_dir, &resolved.python) + .map_err(|e| anyhow::anyhow!("validating the tools venv: {e}"))?; + resolved + } else { + // Default: freshness-gated auto-sync (same path as `venv sync`, + // and as `venv shell`), then run. `--quiet` forwards to uv's + // --quiet inside the sync. + let consent = toolr_core::uv::install::ConsentMode::from_env(); + let (resolved, _uv) = toolr_core::project::ensure_venv_ready( + &cwd, + consent, + toolr_core::project::EnsureOpts::default().with_quiet(quiet), + )?; + resolved + }; + + run_command_in_venv(&resolved.venv_dir, &argv) +} + +/// Activate `venv_dir` (VIRTUAL_ENV + TOOLR_VENV + PATH prepend) and run +/// `argv` in it, inheriting stdio and passing the child's exit code +/// through. A not-found spawn error becomes an actionable nudge (exit +/// 127) instead of a raw OS error. No command echo — the child owns +/// stdout/stderr (parity with `uv run`). +fn run_command_in_venv(venv_dir: &Path, argv: &[String]) -> Result { + use std::process::Command; + + let bin_dir = venv_bin_dir(venv_dir); + let prepended_path = prepend_to_path(&bin_dir, std::env::var_os("PATH").as_deref())?; + let (program, rest) = argv + .split_first() + .expect("clap enforces at least one command token"); + + let result = Command::new(program) // nosemgrep: rust.actix.command-injection.rust-actix-command-injection.rust-actix-command-injection + .args(rest) + .env("VIRTUAL_ENV", venv_dir) + .env("TOOLR_VENV", venv_dir) + .env("PATH", &prepended_path) + .status(); + + match result { + Ok(status) => Ok(ExitCode::from(status.code().unwrap_or(1) as u8)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("toolr: couldn't find `{program}` in the tools venv."); + eprintln!( + "hint: did you forget to add it to tools/pyproject.toml (then `toolr project venv sync`)?" + ); + Ok(ExitCode::from(127)) + } + Err(e) => Err(anyhow::Error::new(e) + .context(format!("spawning `{program}` in the tools venv"))), + } +} +``` + +- [ ] **Step 5: Run the always-on tests to verify they pass** + +Run: `cargo test -p toolr --test project_venv_run` +Expected: PASS — `no_sync_errors_when_venv_missing`, `requires_a_command`, `help_mentions_no_sync` all green. + +- [ ] **Step 6: Add the Unix behavior tests (fake venv fixture)** + +Append to `crates/toolr/tests/project_venv_run.rs`: + +```rust +/// Build an in-tree fake venv at `tools/.venv` good enough for +/// `validate_venv`: a runnable `bin/python` (body supplied) plus a fake +/// installed `toolr` package. Returns the venv dir. Unix-only: writes a +/// `#!/bin/sh` interpreter and 0o755 perms. +#[cfg(unix)] +fn fake_in_tree_venv(repo: &std::path::Path, python_body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + write_tools(repo, IN_TREE_PYPROJECT); + let venv = repo.join("tools").join(".venv"); + let bin = venv.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let python = bin.join("python"); + std::fs::write(&python, python_body).unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + let site = venv.join("lib").join("python3.13").join("site-packages").join("toolr"); + std::fs::create_dir_all(&site).unwrap(); + std::fs::write(site.join("__init__.py"), b"").unwrap(); + venv +} + +/// Mark the venv Fresh: uv.lock first, then the sync stamp (newer mtime). +#[cfg(unix)] +fn mark_fresh(repo: &std::path::Path, venv: &std::path::Path) { + std::fs::write(repo.join("tools").join("uv.lock"), b"lock").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(venv.join(".toolr-sync-stamp"), b"").unwrap(); +} + +#[cfg(unix)] +#[test] +fn no_sync_errors_when_stale() { + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv(tmp.path(), "#!/bin/sh\nexit 0\n"); + // Stamp older than uv.lock → Stale. + std::fs::write(venv.join(".toolr-sync-stamp"), b"").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(tmp.path().join("tools").join("uv.lock"), b"lock").unwrap(); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "python"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("out of date")); +} + +#[cfg(unix)] +#[test] +fn no_sync_fresh_passes_exit_code_through() { + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv(tmp.path(), "#!/bin/sh\nexit 3\n"); + mark_fresh(tmp.path(), &venv); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "python"]) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(3), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +#[test] +fn no_sync_passes_args_verbatim() { + let tmp = TempDir::new().unwrap(); + let arglog = tmp.path().join("arglog"); + let body = format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\nexit 0\n", arglog.display()); + let venv = fake_in_tree_venv(tmp.path(), &body); + mark_fresh(tmp.path(), &venv); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "python", "-k", "foo"]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let logged = std::fs::read_to_string(&arglog).unwrap(); + assert!(logged.lines().any(|l| l == "-k"), "argv: {logged}"); + assert!(logged.lines().any(|l| l == "foo"), "argv: {logged}"); +} + +#[cfg(unix)] +#[test] +fn not_found_command_gets_nudge() { + let tmp = TempDir::new().unwrap(); + let venv = fake_in_tree_venv(tmp.path(), "#!/bin/sh\nexit 0\n"); + mark_fresh(tmp.path(), &venv); + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--no-sync", "--", "toolr-definitely-absent-xyz"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(127), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("couldn't find `toolr-definitely-absent-xyz`"), "stderr: {stderr}"); + assert!(stderr.contains("tools/pyproject.toml"), "stderr: {stderr}"); +} +``` + +- [ ] **Step 7: Run the Unix tests to verify they pass** + +Run: `cargo test -p toolr --test project_venv_run` +Expected: PASS — all 7 tests (3 always-on + 4 unix). On a non-Unix host only the 3 always-on run. + +- [ ] **Step 8: Add an ignored real-venv happy-path test (cross-platform PATHEXT coverage)** + +Append to `crates/toolr/tests/project_venv_run.rs`. This is the only test that exercises bare-command resolution against a *real* venv on Windows (via `--ignored`, network-touching). The always-on tests cover parse + missing/stale everywhere and spawn/exit-code/not-found on Unix. + +```rust +#[test] +#[ignore = "network-touching: requires uv to be available or installable"] +fn runs_in_a_real_synced_venv() { + let tmp = TempDir::new().unwrap(); + write_tools( + tmp.path(), + "[project]\nname=\"toolr-tools\"\nversion=\"0\"\nrequires-python=\">=3.11\"\ndependencies=[\"toolr\"]\n\n[tool.toolr]\nvenv-location = \"in-tree\"\n", + ); + // Default path auto-syncs, then runs `python` from the venv. + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "venv", "run", "--", "python", "-c", "print('ran-in-venv')"]) + .env("TOOLR_AUTO_INSTALL_UV", "1") + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("ran-in-venv")); +} +``` + +- [ ] **Step 9: Verify the crate builds and the whole file compiles (ignored test included)** + +Run: `cargo test -p toolr --test project_venv_run -- --list` +Expected: lists all tests including `runs_in_a_real_synced_venv` (marked ignored); no compile errors. + +- [ ] **Step 10: Commit** + +```bash +git add crates/toolr/src/cli.rs crates/toolr/src/project.rs crates/toolr/tests/project_venv_run.rs +git diff --cached --name-only # confirm ONLY these three paths +git commit -m "feat(cli): add `toolr project venv run` to run commands in the managed venv" +``` + +--- + +## Task 2: Discoverability wiring + +**Files:** + +- Modify: `crates/toolr/src/project.rs` (`run_project_init` next-steps block ~line 168-175; `venv_sync` success message ~line 275-281) +- Test: `crates/toolr/tests/project_venv_run.rs` (extend) + +**Interfaces:** + +- Consumes: the `run` command from Task 1. +- Produces: no new symbols — only added stdout lines that advertise `toolr project venv run -- pytest tools/`. +- [ ] **Step 1: Write the failing test** + +Append to `crates/toolr/tests/project_venv_run.rs`: + +```rust +#[test] +fn project_init_next_steps_mention_venv_run() { + let tmp = TempDir::new().unwrap(); + // --no-sync keeps this offline and fast; we only assert on the + // scaffolding next-steps output, not on a real sync. + let output = Command::cargo_bin("toolr") + .unwrap() + .current_dir(tmp.path()) + .args(["project", "init", "--no-sync"]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("project venv run"), + "next-steps should advertise `venv run`, got: {stdout}" + ); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cargo test -p toolr --test project_venv_run project_init_next_steps_mention_venv_run` +Expected: FAIL — the current next-steps output lists only `example hello` / `example commit` / `self completion install`. + +- [ ] **Step 3: Add the one-liner to *both* `project init` output paths** + +`run_project_init` has two output paths: the `--no-sync` early return (`if no_sync { if !quiet { … } return … }`) and the auto-sync `next steps:` block after it. The Step 1 test runs with `--no-sync`, so the line **must** be in the early-return branch — putting it only in the next-steps block would leave that test red. + +In `crates/toolr/src/project.rs`, in the `if no_sync { if !quiet { … } }` block, after the `run \`toolr project venv sync\` when you are ready` line, add: + +```rust + println!("toolr: then run commands with `toolr project venv run -- ` (e.g. pytest tools/)"); +``` + +And in the auto-sync `next steps:` block (after the `self completion install` line), add: + +```rust + println!("toolr: toolr project venv run -- pytest tools/ # run your tools' tests in the managed venv"); +``` + +- [ ] **Step 4: Add a hint to `venv sync` success output** + +In `venv_sync`, inside the `if !quiet { … }` block after the "synced venv at …" line, add: + +```rust + println!("toolr: run a command in it with `toolr project venv run -- ` (e.g. pytest tools/)"); +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cargo test -p toolr --test project_venv_run project_init_next_steps_mention_venv_run` +Expected: PASS. + +- [ ] **Step 6: Guard against regressing existing init/sync output assertions** + +Run: `cargo test -p toolr --test project_init --test project_venv_sync` +Expected: PASS (added lines are additive; if any test asserts exact full output, update it to include the new line). + +- [ ] **Step 7: Commit** + +```bash +git add crates/toolr/src/project.rs crates/toolr/tests/project_venv_run.rs +git diff --cached --name-only +git commit -m "feat(cli): advertise `venv run` from `project init` and `venv sync`" +``` + +--- + +## Task 3: Docs + completion enumeration + +**Files:** + +- Modify: `crates/toolr/src/builtin_completions.rs` (the enumeration test at ~line 285) +- Modify: `.pre-commit-hooks/regen-doc-snippets.py` (add a `Snippet`, ~line 104) +- Modify: `docs/cli.md` (new section after `### toolr project venv shell`) +- Create-via-regen: `docs/cli-files/project-venv-run-help.txt` + +**Interfaces:** + +- Consumes: the `run` command from Task 1 (completions are auto-derived from `cli::build_command`; only the *test* enumerating them needs updating). +- Produces: no code symbols. +- [ ] **Step 1: Update the completions enumeration test to expect `run`** + +In `crates/toolr/src/builtin_completions.rs`, find the test `project_venv_offers_path_shell_sync_lock_add_remove` (~line 285). Rename it and add `run` to the expected set: + +```rust + #[test] + fn project_venv_offers_run_path_shell_sync_lock_add_remove() { + let m = merged_empty_manifest(); + let out = serve_completions(&m, &tokens(&["project", "venv", ""])); + for expected in ["run", "path", "shell", "sync", "lock", "add", "remove"] { + assert!( + out.contains(&expected.to_string()), + "missing {expected} under project venv, got: {out:?}" + ); + } + } +``` + +(`merged_empty_manifest()` is the helper the neighboring `project_venv_*` completion tests already use — verified in `builtin_completions.rs`.) + +- [ ] **Step 2: Run it to verify it fails, then passes** + +Run: `cargo test -p toolr builtin_completions::tests::project_venv_offers_run` +Expected: FAIL first (until Task 1 is present it fails; with Task 1 present it fails only if the derive is broken). Since Task 1 already added `run` to `build_command`, this should PASS immediately — confirming the auto-derive works end to end. + +- [ ] **Step 3: Register the `--help` snippet** + +In `.pre-commit-hooks/regen-doc-snippets.py`, in the `SNIPPETS` list, add after the `project-venv-shell-help.txt` line (~line 104): + +```python + Snippet(CLI_FILES / "project-venv-run-help.txt", ("project", "venv", "run", "--help")), +``` + +- [ ] **Step 4: Add the docs section** + +In `docs/cli.md`, after the `### toolr project venv shell` section, add: + +````markdown +### `toolr project venv run` {#project-venv-run} + +Run a command inside the managed tools venv. By default it syncs the venv +first (freshness-gated, exactly like [`venv sync`](#project-venv-sync) and +`venv shell`), then runs the command with `$VIRTUAL_ENV`, `$TOOLR_VENV`, and +`$PATH` set so entry points such as `pytest` resolve from the venv. The child's +stdout, stderr, and exit code pass straight through. + +This is the one-liner for running a command-package's tests: + +```console +toolr project venv run -- pytest tools/ +``` + +`--no-sync` never touches the venv — it errors if the venv is missing or stale +instead of syncing. Use it in CI once the venv is known-synced, for +deterministic runs. toolr's own flags must come before the command (or a `--`). + +```console +toolr project venv run --help +``` + +```text +--8<-- "docs/cli-files/project-venv-run-help.txt" +``` +```` + +- [ ] **Step 5: Regenerate the snippet (do NOT hand-write the .txt)** + +Run: `prek run regen-doc-snippets --all-files` (or `python .pre-commit-hooks/regen-doc-snippets.py` per its usage). This creates `docs/cli-files/project-venv-run-help.txt`. +Expected: the new `.txt` appears and contains the `--no-sync`/`--quiet`/`CMD` help. + +- [ ] **Step 6: Build the docs strictly** + +Run: `uv run mkdocs build --strict` +Expected: success, no broken-anchor or missing-include warnings for `project-venv-run`. + +- [ ] **Step 7: Commit** + +```bash +git add crates/toolr/src/builtin_completions.rs .pre-commit-hooks/regen-doc-snippets.py docs/cli.md docs/cli-files/project-venv-run-help.txt +git diff --cached --name-only +git commit -m "docs(cli): document `toolr project venv run`" +``` + +--- + +## Task 4: Skills, release note, skill-refs, archive spec, full verification + +**Files:** + +- Modify: `skills/toolr-command-authoring/SKILL.md`, `skills/toolr-ci-setup/SKILL.md` +- Modify: `UNRELEASED.md` +- Regenerate: skill refs via `cargo xtask build-skill-refs` +- Move: `specs/2026-07-13-venv-run-{design,plan}.md` → `specs/archive/2026/` + +**Interfaces:** + +- Consumes: the shipped command + docs from Tasks 1-3. +- Produces: no code symbols. +- [ ] **Step 1: Update the command-authoring skill prose** + +In `skills/toolr-command-authoring/SKILL.md`, find where it explains running/testing a package's commands (the "how do I run my tests" workflow, currently pointing at the `"$(toolr project venv path)/bin/python" -m pytest` incantation or equivalent). Replace that guidance with: + +```markdown +Run your commands' tests in the managed venv with: + + toolr project venv run -- pytest tools/ + +This syncs the venv if stale, then runs `pytest` from it. No need to hand-build +the `"$(toolr project venv path)/bin/python" -m pytest …` invocation. +``` + +(If the exact old text differs, edit in place to point at `toolr project venv run` — grep the file for `venv path` / `pytest` first.) + +- [ ] **Step 2: Update the CI-setup skill prose** + +In `skills/toolr-ci-setup/SKILL.md`, find the CI recipe that runs a package's tests (the hand-rolled build-venv-then-pytest steps). Replace the run step with `toolr project venv run --no-sync -- pytest tools/` (after a `toolr project venv sync` step), noting `--no-sync` gives deterministic CI runs against the already-synced venv. Grep the file for `pytest` / `venv path` to locate the exact spot. + +- [ ] **Step 3: Add the release note** + +Append to `UNRELEASED.md`: + +```markdown +`toolr project venv run -- ` runs a command inside the managed tools venv — +the first-class one-liner for running a command-package's tests +(`toolr project venv run -- pytest tools/`). By default it syncs the venv first +(like `venv shell`); `--no-sync` runs against the existing venv and errors if it +is missing or stale, for deterministic CI. +``` + +- [ ] **Step 4: Regenerate skill refs** + +Run: `cargo xtask build-skill-refs` +Then verify: `cargo xtask build-skill-refs --check` +Expected: `--check` exits 0 (refs match). Note any regenerated files under the skills' ref directories to stage. + +- [ ] **Step 5: Run the full umbrella suite** + +Run: `mise run test` +Expected: PASS — skill-refs drift gate, `cargo test --workspace`, and pytest all green. Monitor the long `cargo test --workspace` run (poll output every 30–60s; it can stall). + +- [ ] **Step 6: Run all pre-commit hooks** + +Run: `prek run --all-files` +Expected: PASS (formatting, snippets, skill-ref check, typos, etc.). Fix and re-run until clean — do not use `--no-verify`. + +- [ ] **Step 7: Sanity-check no forbidden strings slipped in** + +Run: `git grep -in ; git grep -n "/Users/"` (substitute the actual employer name locally — never commit it). +Expected: no matches in anything staged for this feature. (If the repo has pre-existing hits elsewhere, confirm none are in files this plan touched.) + +- [ ] **Step 8: Commit the skills + release note + regenerated refs** + +```bash +git add skills/ UNRELEASED.md +git diff --cached --name-only # skills prose + regenerated refs + UNRELEASED only +git commit -m "docs(skills): teach `toolr project venv run` for running/testing commands" +``` + +- [ ] **Step 9: Archive the spec (final implementation step)** + +```bash +git mv specs/2026-07-13-venv-run-design.md specs/archive/2026/ +git mv specs/2026-07-13-venv-run-plan.md specs/archive/2026/ +git diff --cached --name-only +git commit -m "docs(internals): archive venv-run design + plan" +``` + +- [ ] **Step 10: Final dogfood check** + +Run: `cargo run -p toolr -- project venv run --help` +Expected: help renders with `--no-sync`, `--quiet`, and the `CMD` positional; the flags-before-command note appears. + +--- + +## Self-Review + +**1. Spec coverage** (each design section → task): + +- Command surface (`run`, `--`, `trailing_var_arg`, required CMD, no `-q`, flag ordering) → Task 1 Step 3. +- Execution semantics (validate before spawn, activate env, preserve cwd, exit-code passthrough, Windows PATHEXT) → Task 1 Steps 4, 6, 8. +- Sync policy (auto-sync default via `ensure_venv_ready`; `--no-sync` fatal on Missing/Stale; no `--sync`; `--quiet` forwards to sync) → Task 1 Step 4; tests Steps 1, 6. +- Command-not-found nudge (argv[0] only; `python -m` passes through) → Task 1 Step 4 (`run_command_in_venv`), test Step 6 `not_found_command_gets_nudge`. +- Output (no command echo) → Global Constraints + `run_command_in_venv` has no echo; covered implicitly (happy-path tests assert only child stdout). +- Discoverability wiring → Task 2. +- Files touched (cli, project, tests, completions, docs, snippet) → Tasks 1-3. +- AI skill updates (both layers) → Task 4 Steps 1-2 (prose) + Step 4 (`build-skill-refs`). +- Non-goals (no `toolr test`, no top-level `run`, no `[tool.toolr]` test config, no output parsing) → nothing implements them; consistent. +- Windows coverage (fix H) → Task 1 Step 8 ignored real-venv test + always-on parse/missing tests; scope stated explicitly (Unix fake-venv tests + ignored cross-platform real-venv test). +- Testing/verification scope → Task 4 Steps 5-6. + +No spec requirement is left without a task. + +**2. Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to Task N". Every code step shows complete code; every command shows expected output. The two skill-prose edits (Task 4 Steps 1-2) say "grep for the exact old text" because the current wording isn't quoted in the spec — this is a locate instruction, not a placeholder; the *replacement* text is given verbatim. + +**3. Type consistency:** `venv_run(&ArgMatches) -> Result` and `run_command_in_venv(&Path, &[String]) -> Result` are used consistently between the handler (Task 1 Step 4) and the dispatch arm. Flag names (`no-sync`, `quiet`, `command`) match between the CLI declaration (Step 3) and `matches.get_flag`/`get_many` (Step 4). `Freshness::{Missing,Stale,Fresh}` match the re-export. The freshness marker filename `.toolr-sync-stamp` in the test fixture matches `FRESHNESS_MARKER` in `sync.rs`. Exit code 127 asserted in the test matches the handler.