Skip to content
Merged
1 change: 1 addition & 0 deletions .pre-commit-hooks/regen-doc-snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
6 changes: 6 additions & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <cmd>` 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.
4 changes: 2 additions & 2 deletions crates/toolr/src/builtin_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
Expand Down
26 changes: 26 additions & 0 deletions crates/toolr/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions crates/toolr/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub fn dispatch_project(matches: &ArgMatches) -> Result<ExitCode> {
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),
Expand Down Expand Up @@ -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 -- <cmd>` (e.g. pytest tools/)"
);
}
return Ok(ExitCode::SUCCESS);
}
Expand Down Expand Up @@ -172,6 +176,9 @@ pub(crate) fn run_project_init(
println!(
"toolr: toolr self completion install <bash|zsh|fish> # optional, for tab completion"
);
println!(
"toolr: toolr project venv run -- pytest tools/ # run your tools' tests in the managed venv"
);
}
Ok(ExitCode::SUCCESS)
}
Expand Down Expand Up @@ -278,6 +285,9 @@ fn venv_sync(matches: &ArgMatches) -> Result<ExitCode> {
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 -- <cmd>` (e.g. pytest tools/)"
);
}
Ok(ExitCode::SUCCESS)
}
Expand Down Expand Up @@ -539,6 +549,89 @@ fn venv_shell() -> Result<ExitCode> {
Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
}

fn venv_run(matches: &ArgMatches) -> Result<ExitCode> {
let no_sync = matches.get_flag("no-sync");
let quiet = matches.get_flag("quiet");
let argv: Vec<String> = matches
.get_many::<String>("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<ExitCode> {
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.
Expand Down
Loading
Loading