From 9fdd94cda01e8a48363525a3950f270e0529300d Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Fri, 31 Jul 2026 22:20:26 -0700 Subject: [PATCH 01/61] refactor(setup nu): replace action flags with subcommands + shared confirm utility Breaking change: --remove, --use-path, --use-existing are replaced by subcommands (remove, path, use ). Hidden backward-compat flags emit deprecation warnings (remove in v0.3.0). - Add src/util/confirm.rs: confirm_or_auto / confirm_or_bail (non-TTY auto-confirms) - Replace 16 inline TTY-check sites across 8 files - Normalize all --yes help text to 'Skip confirmation prompts' - Redesign NuSetupArgs with Option subcommand + positional VERSION - Add NuSetupArgs constructors for internal callers (doctor, nu_pin_offer) - Add 8 CLI-parse tests + update all affected test assertions - Update README, CHANGELOG, docs/numan-doctor.md, AGENTS.md --- AGENTS.md | 10 +- CHANGELOG.md | 15 ++ README.md | 8 +- docs/numan-doctor.md | 8 +- src/cmd/activate.rs | 41 ++--- src/cmd/deactivate.rs | 30 +--- src/cmd/doctor.rs | 24 +-- src/cmd/nu_pin_offer.rs | 8 +- src/cmd/nupm.rs | 2 +- src/cmd/setup.rs | 332 ++++++++++++++++++++++++++++++++-------- src/cmd/snapshot.rs | 15 +- src/cmd/try_cmd.rs | 4 +- src/nu/bootstrap.rs | 79 +++------- src/util/confirm.rs | 57 +++++++ src/util/hints.rs | 8 +- src/util/mod.rs | 1 + tests/activate_test.rs | 19 +-- tests/doctor_test.rs | 13 +- tests/setup_nu_test.rs | 126 +++++++++++---- 19 files changed, 518 insertions(+), 282 deletions(-) create mode 100644 src/util/confirm.rs diff --git a/AGENTS.md b/AGENTS.md index abcd8404..5568be42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,9 +63,9 @@ src/ gc.rs — `numan gc [--dry-run]`: delete orphaned payload directories (Phase 5) nupm.rs — `numan nupm status|inspect|import|diff`: nupm discovery + import + drift (Phase 6.1–6.3) completions.rs — `numan completions `: bash/fish/zsh/powershell/nushell scripts (Phase 7.3) - setup.rs — `numan setup nu [--version]|loader`: Nushell bootstrap (pinned or latest) + nushell-loader install + setup.rs — `numan setup nu [VERSION]|remove|path|use ` + `setup loader`: Nushell bootstrap + nushell-loader install try_cmd.rs — `numan try [--yes] [--no-activate]`: curated starter install + activate for current Nu - nu_pin_offer.rs — Shared TTY offer to `setup nu --version` + `init --refresh` on Nu mismatch + nu_pin_offer.rs — Shared TTY offer to `setup nu ` + `init --refresh` on Nu mismatch install/ download.rs — HTTP download with progress extract.rs — tar/zip/xz archive extraction @@ -229,8 +229,8 @@ Standard build/test/lint/run commands live in "Build & Test" above and in the RE ## Learned Workspace Facts - Plugin ABI is Nu-minor-scoped: mixed plugin ABIs cannot run inside one Nu process; side-by-side Nu profiles would be a separate future product shape, not a near-term substitute for compat UX. -- PATH Nu can be newer than official-registry Windows plugin Nu constraints, so `search` can look fine while `install` fails; use compat-filtered search / `numan try` / `setup nu --version`. -- `numan setup nu --version ` pins a managed Nu release; default without `--version` still installs latest. +- PATH Nu can be newer than official-registry Windows plugin Nu constraints, so `search` can look fine while `install` fails; use compat-filtered search / `numan try` / `setup nu `. +- `numan setup nu ` pins a managed Nu release; bare `numan setup nu` installs latest. Subcommands: `remove`, `path`, `use `. - Numan product spans three repos (`numan`, `numan-registry`, `numan-plugins`); trust is cross-cutting (client verifies, registry signs); there is no separate `numan-registry.trust` product repo. - Near-term adoption bottleneck is thin catalog depth; release handoff is numan-plugins → numan-registry → numan client. - `numan registry sync` only refreshes the local catalog; it does not install packages (`list` stays empty until `install`). @@ -239,4 +239,4 @@ Standard build/test/lint/run commands live in "Build & Test" above and in the RE - Prefers streamlining Nu-compat onboarding as honest search/install UX, a one-shot starter, and an offer-based managed Nu pin (never silent auto-switch of Nu). - Prefers the command name `numan try` for the prove-it-works starter (not `setup demo` / `setup starter`). - Product north star for Numan: make the Nushell package ecosystem more inviting for less experienced users. -- PATH Nu can be newer than official-registry Windows plugin Nu constraints, so `search` can look fine while `install` fails; use compat-filtered search / `numan try` / `setup nu --version`. +- PATH Nu can be newer than official-registry Windows plugin Nu constraints, so `search` can look fine while `install` fails; use compat-filtered search / `numan try` / `setup nu `. diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ee9809..a53ab45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **`numan setup nu` CLI redesign**: action flags (`--remove`, `--use-path`, `--use-existing`) are replaced by subcommands: + - `numan setup nu` — install latest (unchanged) + - `numan setup nu ` — install pinned version (was `--version `) + - `numan setup nu remove` — uninstall managed Nu (was `--remove`) + - `numan setup nu path` — use PATH Nu (was `--use-path`) + - `numan setup nu use ` — register a specific binary (was `--use-existing `) + - Hidden backward-compat flags (`--remove`, `--use-path`, `--use-existing`) still work but emit deprecation warnings. They will be removed in v0.3.0. +- **Non-TTY auto-confirm**: all confirmation prompts now auto-confirm on non-TTY (CI, scripts) instead of requiring `--yes`. A `(non-interactive: auto-confirming)` notice is printed to stderr. `--yes` remains available to skip prompts on TTY. + +### Added + +- Shared confirmation utility (`src/util/confirm.rs`): consistent prompt/auto-confirm behavior across all commands + ## [0.1.5] - 2026-07-29 ### Added diff --git a/README.md b/README.md index bf0b4909..d5ffea0a 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ numan install vyadh/nutest numan activate vyadh/nutest --yes ``` -Install is **inert** — nothing is registered with Nu until you run `numan activate` (or `numan try`, which activates after install). If a package needs a different Nu minor, Numan explains the mismatch and can offer `numan setup nu --version ` (activations are per-Nu; re-activate after switching). When no compatible starter exists, `numan try` suggests installing a matching managed Nu version or searching for another package with `numan search`. +Install is **inert** — nothing is registered with Nu until you run `numan activate` (or `numan try`, which activates after install). If a package needs a different Nu minor, Numan explains the mismatch and can offer `numan setup nu ` (activations are per-Nu; re-activate after switching). When no compatible starter exists, `numan try` suggests installing a matching managed Nu version or searching for another package with `numan search`. After Nu upgrades, refresh cached paths and activation identity: @@ -323,7 +323,10 @@ Global flag: `--root ` — override the Numan root directory (all commands | `numan snapshot delete [--yes]` | Delete a snapshot | | `numan snapshot rollback [--yes]` | Restore exactly a stored snapshot | | `numan registry list\|sync\|add\|remove\|packages` | Registry management | -| `numan setup nu [--version ]` | Download and install official Nushell under Numan root (optionally pinned) | +| `numan setup nu [VERSION]` | Download and install official Nushell under Numan root (optionally pinned) | +| `numan setup nu remove` | Remove the managed Nushell install and fall back to PATH Nu | +| `numan setup nu path` | Use the Nushell already on PATH (removes managed install) | +| `numan setup nu use ` | Register a specific existing Nushell binary | | `numan nupm status` | Summarize nupm home and import eligibility | | `numan nupm inspect [--all] [path]` | Classify nupm packages at a path | | `numan nupm import [--as owner/name] [path]` | One-way import into Numan | @@ -347,6 +350,7 @@ Global flag: `--root ` — override the Numan root directory (all commands | `nupm inspect` | `--all` scan home; `--nupm-home `; `--exit-on-ineligible` fail on ineligible | | `nupm import` | `--as owner/name` (single import); `--manifest ` (batch); `--nupm-home `; `--yes` skip consent | | `doctor` | `--fix` apply safe repairs; `--yes` skip confirm tier; `--json` machine output; `--nupm-home ` | +| `setup nu` | `--force` re-download; `--skip-path` don't update PATH; `--yes` skip prompt | Run `numan --help` for full flag documentation. diff --git a/docs/numan-doctor.md b/docs/numan-doctor.md index 5be58a94..730fb5f8 100644 --- a/docs/numan-doctor.md +++ b/docs/numan-doctor.md @@ -77,8 +77,8 @@ Repair steps run in this **order** (each step re-validates only what it changed) | **auto** | Never | `layout.*` (missing dirs), `nu_paths.missing` | `create_dir_all` for layout; `numan init` | | **auto** | Never | `registry.index_missing` | `numan registry sync` | | **auto** | Never | `registry.none` (production trust root only) | Add official registry via same path as `numan init` | -| **confirm** | Unless `--yes` / non-TTY | `nu.binary.missing_on_path` | `numan setup nu --yes` (downloads managed Nushell) | -| **confirm** | Unless `--yes` / non-TTY | `nu.binary.found_off_path` | `numan setup nu --use-existing --yes` (adds existing install to PATH) | +| **confirm** | Unless `--yes` / non-TTY | `nu.binary.missing_on_path` | `numan setup nu` (downloads managed Nushell) | +| **confirm** | Unless `--yes` / non-TTY | `nu.binary.found_off_path` | `numan setup nu use ` (adds existing install to PATH) | | **confirm** | Unless `--yes` / non-TTY | `nu_paths.drift`, `nu_paths.vendor_drift` | `numan init --refresh` | | **confirm** | Unless `--yes` / non-TTY | `journal.plugin_pending`, `journal.autoload_pending`, `journal.plugin_stale`, `journal.autoload_stale`, `activation.plugin_stale`, `activation.module_stale`, `autoload.projection`, `autoload.managed_missing` | `numan activate` (empty package list — reconciles journals and re-activates stale entries; same entry point as normal activate recovery) | | **confirm** | Unless `--yes` / non-TTY | `journal.plugin_deactivate_pending` | `numan deactivate ` (reconciles pending-plugin-deactivate journal only; not a full-root deactivate) | @@ -115,7 +115,7 @@ Checks run in order below. Implementation should call existing validators (`NuPa | ID | Severity | Condition | |----|----------|-----------| | `nu.binary.missing_on_path` | `error` | Nu not on PATH and not under `$NUMAN_ROOT/tools/nushell/` → fix: `numan setup nu` | -| `nu.binary.found_off_path` | `warn` | Nu exists in a known install root (e.g. `~/.cargo/bin`, `%LOCALAPPDATA%\Programs\nushell`) but not on PATH → fix: `numan setup nu --use-existing --yes` | +| `nu.binary.found_off_path` | `warn` | Nu exists in a known install root (e.g. `~/.cargo/bin`, `%LOCALAPPDATA%\Programs\nushell`) but not on PATH → fix: `numan setup nu use ` | | `nu.path.version` | `info` | PATH-only Nu version (`PATH Nu: 0.114.1`), `PATH Nu: not found`, or `PATH Nu: found at '' but version probe failed ()` when the binary exists but `--version` fails. Does not treat managed Nu as PATH. Report-only (no `--fix`). | | `nu.managed.version` | `info` | Managed binary under `$NUMAN_ROOT/tools/nushell/` with version, `Managed Nu: not installed`, or `Managed Nu: present at '' but version probe failed ()` when the binary exists but `--version` fails. Report-only (no `--fix`). | | `nu_paths.missing` | `error` | `paths.json` absent → fix: `numan init` | @@ -263,7 +263,7 @@ pub fn execute_with_options(args: &DoctorArgs, root: &Path, options: DoctorOptio |---------|------| | `numan init` / `init --refresh` | **Repair** Nu path drift (`--fix` delegates here) | | `numan setup nu` | **Repair** missing Nushell (`nu.binary.missing_on_path`; `--fix` downloads managed binary) | -| `numan setup nu --use-existing` | **Repair** off-PATH Nushell (`nu.binary.found_off_path`; `--fix` adds parent dir to user PATH) | +| `numan setup nu use ` | **Repair** off-PATH Nushell (`nu.binary.found_off_path`; `--fix` adds parent dir to user PATH) | | `numan activate` | **Repair** activation + journal reconciliation (`--fix` delegates here) | | `numan registry sync` | **Repair** missing index cache (`--fix` auto tier) | | `numan activate --check` | Deep **module** check only; no repair | diff --git a/src/cmd/activate.rs b/src/cmd/activate.rs index 102d517b..b1100972 100644 --- a/src/cmd/activate.rs +++ b/src/cmd/activate.rs @@ -1,6 +1,5 @@ use anyhow::{bail, Context, Result}; use clap::Args; -use std::io::{IsTerminal, Write}; use std::path::{Component, Path, PathBuf}; use crate::nu::autoload::{ @@ -26,7 +25,7 @@ pub struct ActivateArgs { /// Package IDs (owner/name) to activate. Omit to activate all installed inactive packages. pub packages: Vec, - /// Skip confirmation prompt + /// Skip confirmation prompts #[arg(long)] pub yes: bool, @@ -163,22 +162,7 @@ fn execute_with_registrar_and_runner( &nu_paths.plugin_registry_path, ); - if !std::io::stdin().is_terminal() && !args.yes { - bail!( - "Interactive confirmation required for non-TTY sessions. \ - Pass --yes to activate without prompting." - ); - } - - if !args.yes { - print!("Proceed? [y/N] "); - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - bail!("Activation cancelled."); - } - } + crate::util::confirm::confirm_or_bail("Proceed?", args.yes, "Activation cancelled.")?; // 9. Reacquire the root mutation lock after consent. let _lock = acquire_mutation_lock(root)?; @@ -1699,19 +1683,14 @@ mod tests { } #[test] - fn non_tty_without_yes_is_caught_before_mutation() { - // This test verifies the non-TTY check logic. - // In a test environment stdin is not a TTY, so we just verify the - // conditional logic is wired correctly by checking the error message - // expected from that code path. - let is_tty = std::io::stdin().is_terminal(); - // In CI / test environments stdin is not a TTY. - if !is_tty { - // The check `!std::io::stdin().is_terminal() && !args.yes` would - // bail here. We can't directly invoke execute() without full state, - // so we just verify the constant matches what the plan expects. - let expected_fragment = "Interactive confirmation required for non-TTY sessions"; - assert!(expected_fragment.contains("non-TTY")); + fn non_tty_auto_confirms() { + // With the shared confirm utility, non-TTY sessions auto-confirm + // instead of bailing. Verify confirm_or_auto returns Ok(true) + // when stdin is not a terminal (as in CI/test environments). + use std::io::IsTerminal; + if !std::io::stdin().is_terminal() { + let result = crate::util::confirm::confirm_or_auto("test?", false); + assert!(result.unwrap(), "non-TTY should auto-confirm"); } } } diff --git a/src/cmd/deactivate.rs b/src/cmd/deactivate.rs index 4fc470c5..c82e2830 100644 --- a/src/cmd/deactivate.rs +++ b/src/cmd/deactivate.rs @@ -1,7 +1,6 @@ use anyhow::{bail, Context, Result}; use clap::Args; use std::collections::BTreeSet; -use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use crate::nu::autoload::{ @@ -40,7 +39,7 @@ pub struct DeactivateArgs { /// Package IDs (owner/name) to deactivate. Omit to deactivate all active plugins and modules. pub packages: Vec, - /// Skip confirmation prompt + /// Skip confirmation prompts #[arg(long)] pub yes: bool, @@ -150,22 +149,7 @@ pub fn execute_with_candidate_runner_and_unregistrar( // 5. Show consent table and confirm print_consent_table(&targets_requested, &nu_paths.plugin_registry_path); - if !std::io::stdin().is_terminal() && !args.yes { - bail!( - "Interactive confirmation required for non-TTY sessions. \ - Pass --yes to deactivate without prompting." - ); - } - - if !args.yes { - print!("Proceed? [y/N] "); - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - bail!("Deactivation cancelled."); - } - } + crate::util::confirm::confirm_or_bail("Proceed?", args.yes, "Deactivation cancelled.")?; // 6. Reacquire the root mutation lock after consent. let _lock = acquire_mutation_lock(root)?; @@ -1612,11 +1596,11 @@ mod tests { } #[test] - fn non_tty_without_yes_is_expected_to_fail() { - let is_tty = std::io::stdin().is_terminal(); - if !is_tty { - let expected = "Interactive confirmation required for non-TTY sessions"; - assert!(expected.contains("non-TTY")); + fn non_tty_auto_confirms() { + use std::io::IsTerminal; + if !std::io::stdin().is_terminal() { + let result = crate::util::confirm::confirm_or_auto("test?", false); + assert!(result.unwrap(), "non-TTY should auto-confirm"); } } diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index fef32557..cc811227 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -45,7 +45,7 @@ pub struct DoctorArgs { #[arg(long)] pub fix: bool, - /// Skip confirmation prompts for confirm-tier repairs (non-TTY implies --yes) + /// Skip confirmation prompts for confirm-tier repairs #[arg(long)] pub yes: bool, @@ -1077,16 +1077,7 @@ fn apply_repairs( }); } else if let Some(off_path) = resolve_off_path(options) { let setup_fn = options.nu_setup_repair.unwrap_or(setup::execute_nu_impl); - match setup_fn( - &NuSetupArgs { - force: false, - skip_path: false, - yes: true, - version: None, - use_existing: Some(off_path), - }, - root, - ) { + match setup_fn(&NuSetupArgs::use_existing(off_path, true), root) { Ok(()) => records.push(RepairRecord { id, status: RepairStatus::Applied, @@ -1126,16 +1117,7 @@ fn apply_repairs( }); } else { let setup_fn = options.nu_setup_repair.unwrap_or(setup::execute_nu_impl); - match setup_fn( - &NuSetupArgs { - force: false, - skip_path: false, - yes: true, - version: None, - use_existing: None, - }, - root, - ) { + match setup_fn(&NuSetupArgs::install(None, false, false, true), root) { Ok(()) => records.push(RepairRecord { id, status: RepairStatus::Applied, diff --git a/src/cmd/nu_pin_offer.rs b/src/cmd/nu_pin_offer.rs index 82aba78b..6ff90e9c 100644 --- a/src/cmd/nu_pin_offer.rs +++ b/src/cmd/nu_pin_offer.rs @@ -59,13 +59,7 @@ pub fn offer_managed_nu_pin( pub fn install_pinned_nu_and_refresh(root: &Path, pin: &str) -> Result<()> { setup::execute_nu( - &NuSetupArgs { - force: true, - skip_path: false, - yes: true, - version: Some(pin.to_string()), - use_existing: None, - }, + &NuSetupArgs::install(Some(pin.to_string()), true, false, true), root, ) .with_context(|| format!("Failed to install managed Nu {pin}"))?; diff --git a/src/cmd/nupm.rs b/src/cmd/nupm.rs index 727fd156..ffb425e7 100644 --- a/src/cmd/nupm.rs +++ b/src/cmd/nupm.rs @@ -69,7 +69,7 @@ pub struct ImportArgs { #[arg(long, value_name = "OWNER/NAME")] pub r#as: Option, - /// Confirm import without interactive consent + /// Skip confirmation prompts #[arg(long)] pub yes: bool, } diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index 36c14d47..4fd10ce9 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -1,11 +1,11 @@ +use crate::util::confirm::confirm_or_bail; use anyhow::{bail, Context, Result}; use clap::{Args, Subcommand}; -use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use crate::core::platform::Platform; use crate::nu::bootstrap::{self, NuSetupOptions}; -use crate::nu::paths::{find_nu_executable_with_root, probe_nu_config_path}; +use crate::nu::paths::{find_nu_executable_with_root, find_nu_on_path, probe_nu_config_path}; use crate::util::atomic::write_bytes_atomic; use crate::util::fs_safety::{acquire_mutation_lock, assert_not_symlink}; @@ -28,6 +28,14 @@ pub enum SetupCommands { #[derive(Debug, Args)] pub struct NuSetupArgs { + /// Action to perform (default: install) + #[command(subcommand)] + pub action: Option, + + /// Nushell version to install (e.g. 0.113.1); omit for latest + #[arg(value_name = "VERSION")] + pub version: Option, + /// Re-download and replace an existing managed Nushell install #[arg(long)] pub force: bool, @@ -36,19 +44,90 @@ pub struct NuSetupArgs { #[arg(long)] pub skip_path: bool, - /// Skip confirmation prompts (required for non-TTY downloads) + /// Skip confirmation prompts #[arg(long)] pub yes: bool, - /// Install a specific Nushell release (e.g. 0.113.1) instead of latest - #[arg(long, value_name = "VERSION")] - pub version: Option, - - /// Use an existing Nushell binary (add its directory to PATH instead of downloading) - #[arg(long, value_name = "PATH", conflicts_with_all = ["force", "version"])] + // COMPAT: remove in v0.3.0 — hidden backward-compat flags + #[arg(long, hide = true)] + pub remove: bool, + #[arg(long, hide = true)] + pub use_path: bool, + #[arg(long, hide = true, value_name = "PATH")] pub use_existing: Option, } +#[derive(Debug, Subcommand)] +pub enum NuAction { + /// Remove the managed Nushell install and fall back to PATH Nu + Remove, + /// Use the Nushell already on PATH (remove managed install, no download) + Path, + /// Use a specific existing Nushell binary + Use { + /// Path to the Nu binary + path: PathBuf, + }, +} + +impl NuSetupArgs { + /// Construct args for installing a managed Nu (latest or pinned). + pub fn install(version: Option, force: bool, skip_path: bool, yes: bool) -> Self { + Self { + action: None, + version, + force, + skip_path, + yes, + remove: false, + use_path: false, + use_existing: None, + } + } + + /// Construct args for switching to the PATH Nu. + pub fn use_path(yes: bool) -> Self { + Self { + action: Some(NuAction::Path), + version: None, + force: false, + skip_path: false, + yes, + remove: false, + use_path: false, + use_existing: None, + } + } + + /// Construct args for registering a specific existing Nu binary. + pub fn use_existing(path: PathBuf, yes: bool) -> Self { + Self { + action: Some(NuAction::Use { path }), + version: None, + force: false, + skip_path: false, + yes, + remove: false, + use_path: false, + use_existing: None, + } + } + + /// Construct args for removing the managed Nu. + pub fn remove(yes: bool) -> Self { + Self { + action: Some(NuAction::Remove), + version: None, + force: false, + skip_path: false, + yes, + remove: false, + use_path: false, + use_existing: None, + } + } +} + #[derive(Debug, Args)] pub struct LoaderArgs { /// Overwrite an existing loader.nu without prompting @@ -59,7 +138,7 @@ pub struct LoaderArgs { #[arg(long)] pub configure: bool, - /// Skip confirmation prompts (required for non-TTY when overwriting or configuring) + /// Skip confirmation prompts #[arg(long)] pub yes: bool, } @@ -78,27 +157,121 @@ pub fn execute_nu(args: &NuSetupArgs, root: &Path) -> Result<()> { /// Setup Nu without acquiring the mutation lock (caller must hold it). pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { - if args.use_existing.is_some() && args.skip_path { - bail!( - "numan setup nu --use-existing cannot be combined with --skip-path. \ - Off-PATH registration must persist the binary directory to PATH; \ - omit --skip-path or pass --skip-path only for managed downloads." - ); + // COMPAT: remove in v0.3.0 — translate hidden legacy flags to subcommands + if args.remove { + eprintln!("warning: --remove is deprecated, use 'numan setup nu remove' instead"); + return remove_managed_nu(root, args.yes); + } + if args.use_path { + eprintln!("warning: --use-path is deprecated, use 'numan setup nu path' instead"); + return execute_use_path(args.yes, root); + } + if let Some(existing) = &args.use_existing { + eprintln!("warning: --use-existing is deprecated, use 'numan setup nu use ' instead"); + return execute_use_existing(existing, args.yes, root); } + match &args.action { + Some(NuAction::Remove) => remove_managed_nu(root, args.yes), + Some(NuAction::Path) => execute_use_path(args.yes, root), + Some(NuAction::Use { path }) => { + if args.skip_path { + bail!( + "numan setup nu use cannot be combined with --skip-path. \ + Off-PATH registration must persist the binary directory to PATH." + ); + } + execute_use_existing(path, args.yes, root) + } + None => { + // Default: install (latest or pinned version) + let options = NuSetupOptions { + yes: args.yes, + force: args.force, + skip_path: args.skip_path, + version: args.version.clone(), + }; + let platform = Platform::detect(); + bootstrap::execute_nu_setup(root, &platform, &options)?; + Ok(()) + } + } +} + +fn execute_use_path(yes: bool, root: &Path) -> Result<()> { + let path_nu = find_nu_on_path()?; + println!("Found Nu on PATH: {path_nu}"); + remove_managed_nu_if_present(root)?; let options = NuSetupOptions { - yes: args.yes, - force: args.force, - skip_path: args.skip_path, - version: args.version.clone(), + yes, + force: false, + skip_path: false, + version: None, }; - if let Some(existing) = &args.use_existing { - bootstrap::register_existing_nu(existing, &options)?; + bootstrap::register_existing_nu(Path::new(&path_nu), &options)?; + Ok(()) +} + +fn execute_use_existing(path: &Path, yes: bool, root: &Path) -> Result<()> { + remove_managed_nu_if_present(root)?; + let options = NuSetupOptions { + yes, + force: false, + skip_path: false, + version: None, + }; + bootstrap::register_existing_nu(path, &options)?; + Ok(()) +} + +/// Remove the managed Nushell install, prompting unless `--yes`. +fn remove_managed_nu(root: &Path, yes: bool) -> Result<()> { + let managed_dir = bootstrap::managed_nu_dir(root); + if !managed_dir.is_dir() { + println!( + "No managed Nushell install found at '{}'.", + managed_dir.display() + ); return Ok(()); } - let platform = Platform::detect(); - bootstrap::execute_nu_setup(root, &platform, &options)?; + confirm_or_bail( + &format!( + "Remove managed Nushell at '{}'? Numan will fall back to PATH Nu.", + managed_dir.display() + ), + yes, + "Managed Nushell removal cancelled.", + )?; + + std::fs::remove_dir_all(&managed_dir).with_context(|| { + format!( + "Failed to remove managed Nushell directory '{}'", + managed_dir.display() + ) + })?; + println!( + "Removed managed Nushell at '{}'. Run 'numan init --refresh' to re-detect Nu.", + managed_dir.display() + ); + Ok(()) +} + +/// Silently remove the managed Nu directory if it exists (used by --use-existing). +fn remove_managed_nu_if_present(root: &Path) -> Result<()> { + let managed_dir = bootstrap::managed_nu_dir(root); + if managed_dir.is_dir() { + std::fs::remove_dir_all(&managed_dir).with_context(|| { + format!( + "Failed to remove managed Nushell directory '{}'", + managed_dir.display() + ) + })?; + println!( + "Removed managed Nushell at '{}' (replaced by --use-existing).", + managed_dir.display() + ); + } Ok(()) } @@ -161,25 +334,14 @@ fn install_loader_file(loader_path: &Path, args: &LoaderArgs) -> Result<()> { return Ok(()); } - if !std::io::stdin().is_terminal() && !args.yes { - bail!( - "loader.nu already exists at '{}'. Pass --force to overwrite, or --yes in non-TTY sessions.", - loader_path.display() - ); - } - - if !args.yes { - print!( - "loader.nu already exists at '{}'. Overwrite with the vendored copy? [y/N] ", + confirm_or_bail( + &format!( + "loader.nu already exists at '{}'. Overwrite with the vendored copy?", loader_path.display() - ); - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - bail!("Loader install cancelled."); - } - } + ), + args.yes, + "Loader install cancelled.", + )?; } assert_not_symlink(loader_path, "loader.nu")?; @@ -215,25 +377,12 @@ fn configure_config_nu(config_path: &Path, args: &LoaderArgs) -> Result<()> { return Ok(()); } - if !std::io::stdin().is_terminal() && !args.yes { - bail!( - "Interactive confirmation required to modify config.nu in non-TTY sessions. \ - Pass --yes to append the loader source line." - ); - } - - if !args.yes { - print!( - "Append loader source line to '{}'? [y/N] ", - config_path.display() - ); - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - print_manual_snippet(config_path); - return Ok(()); - } + if !crate::util::confirm::confirm_or_auto( + &format!("Append loader source line to '{}'?", config_path.display()), + args.yes, + )? { + print_manual_snippet(config_path); + return Ok(()); } let updated = format!("{}{CONFIG_SNIPPET}", content.trim_end()); @@ -406,4 +555,65 @@ mod tests { let config = std::fs::read_to_string(&config_path).unwrap(); assert!(config_already_sources_loader(&config)); } + + #[test] + fn remove_managed_nu_removes_directory() { + let dir = TempDir::new().unwrap(); + let root = dir.path().join("numan-root"); + let managed_dir = bootstrap::managed_nu_dir(&root); + std::fs::create_dir_all(&managed_dir).unwrap(); + std::fs::write(managed_dir.join("nu.exe"), b"fake").unwrap(); + + remove_managed_nu(&root, true).unwrap(); + assert!(!managed_dir.exists()); + } + + #[test] + fn remove_managed_nu_noop_when_absent() { + let dir = TempDir::new().unwrap(); + let root = dir.path().join("numan-root"); + // Should succeed without error when nothing is installed. + remove_managed_nu(&root, true).unwrap(); + } + + #[test] + fn remove_managed_nu_if_present_clears_directory() { + let dir = TempDir::new().unwrap(); + let root = dir.path().join("numan-root"); + let managed_dir = bootstrap::managed_nu_dir(&root); + std::fs::create_dir_all(&managed_dir).unwrap(); + std::fs::write(managed_dir.join("nu.exe"), b"fake").unwrap(); + + remove_managed_nu_if_present(&root).unwrap(); + assert!(!managed_dir.exists()); + } + + #[test] + fn remove_managed_nu_if_present_noop_when_absent() { + let dir = TempDir::new().unwrap(); + let root = dir.path().join("numan-root"); + remove_managed_nu_if_present(&root).unwrap(); + } + + #[test] + fn execute_nu_impl_remove_flag_delegates() { + let dir = TempDir::new().unwrap(); + let root = dir.path().join("numan-root"); + let managed_dir = bootstrap::managed_nu_dir(&root); + std::fs::create_dir_all(&managed_dir).unwrap(); + std::fs::write(managed_dir.join("nu.exe"), b"fake").unwrap(); + + let args = NuSetupArgs { + action: None, + version: None, + force: false, + skip_path: false, + yes: true, + remove: true, + use_path: false, + use_existing: None, + }; + execute_nu_impl(&args, &root).unwrap(); + assert!(!managed_dir.exists()); + } } diff --git a/src/cmd/snapshot.rs b/src/cmd/snapshot.rs index 441a7df3..6e824822 100644 --- a/src/cmd/snapshot.rs +++ b/src/cmd/snapshot.rs @@ -1,6 +1,5 @@ -use anyhow::{bail, Result}; +use anyhow::Result; use clap::Subcommand; -use std::io::{IsTerminal, Write}; use std::path::Path; use crate::nu::autoload::NuCandidateRunner; @@ -241,17 +240,7 @@ fn rollback(root: &Path, id: &str, yes: bool) -> Result<()> { } fn confirm(prompt: &str) -> Result<()> { - if !std::io::stdin().is_terminal() { - bail!("Interactive confirmation required for non-TTY sessions. Pass --yes to proceed without prompting."); - } - print!("{prompt} [y/N] "); - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - bail!("Cancelled."); - } - Ok(()) + crate::util::confirm::confirm_or_bail(prompt, false, "Cancelled.") } fn short_hash(h: &str) -> String { diff --git a/src/cmd/try_cmd.rs b/src/cmd/try_cmd.rs index b546b0dd..cd727729 100644 --- a/src/cmd/try_cmd.rs +++ b/src/cmd/try_cmd.rs @@ -20,7 +20,7 @@ use crate::util::hints::{self, CMD_REGISTRY_SYNC}; /// a matching managed Nu version or searching for another package with `numan search`. #[derive(Parser, Debug)] pub struct TryArgs { - /// Skip confirmation prompts (still will not silent-switch Nu) + /// Skip confirmation prompts #[arg(long)] pub yes: bool, @@ -423,7 +423,7 @@ mod tests { format_no_compatible_starter("0.114.1", "x86_64-pc-windows-msvc", Some("0.113.1")); assert!(msg.contains("0.114.1"), "{msg}"); assert!(msg.contains("x86_64-pc-windows-msvc"), "{msg}"); - assert!(msg.contains("setup nu --version 0.113.1"), "{msg}"); + assert!(msg.contains("setup nu 0.113.1"), "{msg}"); assert!(msg.contains("numan search "), "{msg}"); assert!( !msg.contains("registry sync"), diff --git a/src/nu/bootstrap.rs b/src/nu/bootstrap.rs index 23f8171b..4a630452 100644 --- a/src/nu/bootstrap.rs +++ b/src/nu/bootstrap.rs @@ -2,7 +2,6 @@ use anyhow::{bail, Context, Result}; use serde::Deserialize; -use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use crate::core::integrity; @@ -444,26 +443,15 @@ pub fn register_existing_nu(binary: &Path, options: &NuSetupOptions) -> Result

Result { + if yes { + return Ok(true); + } + + if !std::io::stdin().is_terminal() { + eprintln!("(non-interactive: auto-confirming)"); + return Ok(true); + } + + print!("{prompt} [y/N] "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(input.trim().eq_ignore_ascii_case("y")) +} + +/// Prompt for confirmation, bailing with `cancel_msg` when the user declines. +/// +/// Convenience wrapper for the common "confirm or abort" pattern. +pub fn confirm_or_bail(prompt: &str, yes: bool, cancel_msg: &str) -> Result<()> { + if confirm_or_auto(prompt, yes)? { + Ok(()) + } else { + anyhow::bail!("{cancel_msg}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn yes_flag_skips_prompt() { + assert!(confirm_or_auto("Proceed?", true).unwrap()); + } + + #[test] + fn confirm_or_bail_passes_on_yes() { + assert!(confirm_or_bail("Proceed?", true, "Cancelled.").is_ok()); + } +} diff --git a/src/util/hints.rs b/src/util/hints.rs index b5dab2c1..65687c64 100644 --- a/src/util/hints.rs +++ b/src/util/hints.rs @@ -21,9 +21,9 @@ pub const CMD_DOCTOR_FIX: &str = "numan doctor --fix"; /// `numan setup nu` pub const CMD_SETUP_NU: &str = "numan setup nu"; -/// `numan setup nu --version ` +/// `numan setup nu ` pub fn setup_nu_version(version: &str) -> String { - format!("numan setup nu --version {version}") + format!("numan setup nu {version}") } /// `numan try` @@ -32,9 +32,9 @@ pub const CMD_TRY: &str = "numan try"; /// `numan setup loader` pub const CMD_SETUP_LOADER: &str = "numan setup loader"; -/// `numan setup nu --use-existing --yes` +/// `numan setup nu use ` pub fn setup_nu_use_existing(path: &std::path::Path) -> String { - format!("numan setup nu --use-existing {} --yes", path.display()) + format!("numan setup nu use {}", path.display()) } /// `numan registry add …` diff --git a/src/util/mod.rs b/src/util/mod.rs index c268d8b7..d0eb8fc7 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,4 +1,5 @@ pub mod atomic; +pub mod confirm; pub mod fs_safety; pub mod hints; diff --git a/tests/activate_test.rs b/tests/activate_test.rs index 9d3512a8..d4f72b84 100644 --- a/tests/activate_test.rs +++ b/tests/activate_test.rs @@ -609,14 +609,12 @@ fn test_activate_stale_journal_requires_refresh() { } #[test] -fn test_activate_requires_consent_no_tty() { - // Without --yes and no TTY, activate must refuse rather than silently proceeding. - // We can't simulate a real TTY in tests, but we can verify that the `yes: false` - // path bails before calling the registrar when stdin is not a terminal. +fn test_activate_auto_confirms_on_no_tty() { + // With the shared confirm utility, non-TTY sessions auto-confirm instead of + // bailing. Without --yes and no TTY, activate should proceed (auto-confirm). // - // In CI (non-TTY), this test verifies the guard fires. - // In interactive dev (TTY), this test is skipped by the `is_terminal` check - // — the guard only fires in non-TTY contexts. + // In CI (non-TTY), this test verifies auto-confirm works. + // In interactive dev (TTY), this test is skipped by the `is_terminal` check. if std::io::stdin().is_terminal() { // Running interactively — cannot test non-TTY path here; skip return; @@ -641,11 +639,10 @@ fn test_activate_requires_consent_no_tty() { check: false, }; let result = execute_with_registrar(&args, &env.root(), &ok_registrar); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); assert!( - msg.contains("--yes") || msg.contains("TTY") || msg.contains("Interactive"), - "Expected non-TTY consent error, got: {msg}" + result.is_ok(), + "non-TTY should auto-confirm, got error: {:?}", + result.err() ); } diff --git a/tests/doctor_test.rs b/tests/doctor_test.rs index 8ebbafcc..13cb4a97 100644 --- a/tests/doctor_test.rs +++ b/tests/doctor_test.rs @@ -33,10 +33,13 @@ fn nu_setup_repair_test( _root: &Path, ) -> anyhow::Result<()> { let expected = TEST_OFF_PATH.lock().unwrap(); - assert_eq!( - args.use_existing.as_deref(), - expected.as_ref().map(|p| p.as_path()) - ); + // The doctor passes the off-path binary via NuSetupArgs::use_existing(), + // which sets action = Some(NuAction::Use { path }). + let actual = match &args.action { + Some(numan_cli::cmd::setup::NuAction::Use { path }) => Some(path.as_path()), + _ => args.use_existing.as_deref(), + }; + assert_eq!(actual, expected.as_ref().map(|p| p.as_path())); assert!(args.yes); *TEST_NU_SETUP_CALLED.lock().unwrap() = true; Ok(()) @@ -236,7 +239,7 @@ fn doctor_reports_off_path_nu_without_download() { .find(|f| f.id == "nu.binary.found_off_path") .expect("expected nu.binary.found_off_path"); assert_eq!(finding.severity, Severity::Warn); - assert!(finding.fix.as_deref().unwrap().contains("--use-existing")); + assert!(finding.fix.as_deref().unwrap().contains("setup nu use")); let missing = report .findings diff --git a/tests/setup_nu_test.rs b/tests/setup_nu_test.rs index f2a15c87..ccc5d4bf 100644 --- a/tests/setup_nu_test.rs +++ b/tests/setup_nu_test.rs @@ -3,7 +3,9 @@ //! Tests that invoke a real Nushell binary are marked `#[ignore]` and run in the //! Real-Nu acceptance CI job (`cargo test -- --ignored`). -use numan_cli::cmd::setup::{execute_nu, NuSetupArgs}; +use clap::Parser; +use numan_cli::cli::Cli; +use numan_cli::cmd::setup::{execute_nu, NuAction, NuSetupArgs}; use numan_cli::core::platform::Platform; use numan_cli::nu::bootstrap::{self, install_from_archive, NuSetupOptions}; use numan_cli::nu::paths::{find_nu_executable_with_root, validate_nushell_binary}; @@ -81,17 +83,7 @@ fn execute_nu_command_wraps_installer() { std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); std::fs::write(&binary, b"fake nu").unwrap(); - execute_nu( - &NuSetupArgs { - force: false, - skip_path: true, - yes: true, - version: None, - use_existing: None, - }, - root, - ) - .unwrap(); + execute_nu(&NuSetupArgs::install(None, false, true, true), root).unwrap(); } /// Return the first runnable Nushell binary on `$PATH` (or `/usr/local/bin/nu` on Unix). @@ -134,17 +126,7 @@ fn setup_nu_use_existing_registers_binary_without_download() { std::fs::set_permissions(&existing, perms).unwrap(); } - execute_nu( - &NuSetupArgs { - force: false, - skip_path: false, - yes: true, - version: None, - use_existing: Some(existing.clone()), - }, - root, - ) - .unwrap(); + execute_nu(&NuSetupArgs::use_existing(existing.clone(), true), root).unwrap(); assert!( !bootstrap::managed_nu_binary(root).is_file(), @@ -167,6 +149,82 @@ fn setup_nu_use_existing_registers_binary_without_download() { ); } +// --------------------------------------------------------------------------- +// CLI-parse tests: verify the subcommand tree resolves as expected +// --------------------------------------------------------------------------- + +fn parse_nu_args(argv: &[&str]) -> NuSetupArgs { + let mut full: Vec<&str> = vec!["numan", "setup", "nu"]; + full.extend_from_slice(argv); + let cli = Cli::try_parse_from(&full).unwrap(); + match cli.command { + numan_cli::cli::Commands::Setup(numan_cli::cmd::setup::SetupCommands::Nu(args)) => args, + _ => panic!("expected Setup(Nu) variant"), + } +} + +#[test] +fn cli_parse_bare_install() { + let args = parse_nu_args(&[]); + assert!(args.action.is_none()); + assert!(args.version.is_none()); + assert!(!args.force); + assert!(!args.yes); +} + +#[test] +fn cli_parse_pinned_version() { + let args = parse_nu_args(&["0.113.1"]); + assert!(args.action.is_none()); + assert_eq!(args.version.as_deref(), Some("0.113.1")); +} + +#[test] +fn cli_parse_remove_subcommand() { + let args = parse_nu_args(&["remove"]); + assert!(matches!(args.action, Some(NuAction::Remove))); +} + +#[test] +fn cli_parse_path_subcommand() { + let args = parse_nu_args(&["path"]); + assert!(matches!(args.action, Some(NuAction::Path))); +} + +#[test] +fn cli_parse_use_subcommand() { + let args = parse_nu_args(&["use", "/usr/bin/nu"]); + match &args.action { + Some(NuAction::Use { path }) => assert_eq!(path, &PathBuf::from("/usr/bin/nu")), + other => panic!("expected Use, got {other:?}"), + } +} + +#[test] +fn cli_parse_backward_compat_remove_flag() { + let args = parse_nu_args(&["--remove", "--yes"]); + assert!(args.remove); + assert!(args.yes); + assert!(args.action.is_none()); +} + +#[test] +fn cli_parse_backward_compat_use_path_flag() { + let args = parse_nu_args(&["--use-path"]); + assert!(args.use_path); + assert!(args.action.is_none()); +} + +#[test] +fn cli_parse_backward_compat_use_existing_flag() { + let args = parse_nu_args(&["--use-existing", "C:\\nu.exe"]); + assert_eq!( + args.use_existing.as_deref(), + Some(std::path::Path::new("C:\\nu.exe")) + ); + assert!(args.action.is_none()); +} + #[test] fn setup_nu_rejects_use_existing_with_skip_path() { let dir = tempfile::tempdir().unwrap(); @@ -174,17 +232,17 @@ fn setup_nu_rejects_use_existing_with_skip_path() { let existing = root.join("nu"); std::fs::write(&existing, b"fake nu").unwrap(); - let err = execute_nu( - &NuSetupArgs { - force: false, - skip_path: true, - yes: true, - version: None, - use_existing: Some(existing), - }, - root, - ) - .unwrap_err(); + let args = NuSetupArgs { + action: Some(numan_cli::cmd::setup::NuAction::Use { path: existing }), + version: None, + force: false, + skip_path: true, + yes: true, + remove: false, + use_path: false, + use_existing: None, + }; + let err = execute_nu(&args, root).unwrap_err(); assert!( err.to_string() .contains("cannot be combined with --skip-path"), From 633c50c723180bd250e70cd6e958833a3e212d24 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:25:55 +0000 Subject: [PATCH 02/61] =?UTF-8?q?fix:=203=20findings=20=E2=80=94=20Prevent?= =?UTF-8?q?=20PATH=20subcommand=20from=20deleting=20active=20managed=20N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prevent PATH subcommand from deleting active managed Nu - Guard loader overwrites with ownership verification - Reject incompatible legacy Nu setup flags --- assets/nushell-loader/loader.nu | 2 ++ src/cmd/setup.rs | 46 ++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/assets/nushell-loader/loader.nu b/assets/nushell-loader/loader.nu index 3c02eabd..eab72090 100644 --- a/assets/nushell-loader/loader.nu +++ b/assets/nushell-loader/loader.nu @@ -1,3 +1,5 @@ +# Generated and managed by Numan. Do not edit. +# Numan autoload schema: 1 # Vendored from https://github.com/aidnem/nushell-loader (MIT, Copyright (c) 2026 aidnem) # Installed by `numan setup loader`. Re-run with --force to update. diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index 4fd10ce9..f6b0a679 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -7,7 +7,9 @@ use crate::core::platform::Platform; use crate::nu::bootstrap::{self, NuSetupOptions}; use crate::nu::paths::{find_nu_executable_with_root, find_nu_on_path, probe_nu_config_path}; use crate::util::atomic::write_bytes_atomic; -use crate::util::fs_safety::{acquire_mutation_lock, assert_not_symlink}; +use crate::util::fs_safety::{ + acquire_mutation_lock, assert_managed_file_owned, assert_not_symlink, +}; const VENDOR_LOADER: &str = include_str!("../../assets/nushell-loader/loader.nu"); @@ -168,6 +170,12 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { } if let Some(existing) = &args.use_existing { eprintln!("warning: --use-existing is deprecated, use 'numan setup nu use ' instead"); + if args.skip_path { + bail!( + "numan setup nu --use-existing cannot be combined with --skip-path. \ + Off-PATH registration must persist the binary directory to PATH." + ); + } return execute_use_existing(existing, args.yes, root); } @@ -201,6 +209,22 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { fn execute_use_path(yes: bool, root: &Path) -> Result<()> { let path_nu = find_nu_on_path()?; println!("Found Nu on PATH: {path_nu}"); + + let managed_dir = bootstrap::managed_nu_dir(root); + if managed_dir.is_dir() { + let resolved_path_nu = path_nu + .canonicalize() + .with_context(|| format!("Failed to resolve PATH Nu '{}'", path_nu.display()))?; + let resolved_managed_dir = managed_dir.canonicalize().with_context(|| { + format!("Failed to resolve managed Nushell directory '{}'", managed_dir.display()) + })?; + if resolved_path_nu.starts_with(&resolved_managed_dir) { + bail!( + "PATH Nu resolves to the managed install; install a separate Nu or use `setup nu remove`." + ); + } + } + remove_managed_nu_if_present(root)?; let options = NuSetupOptions { yes, @@ -334,14 +358,18 @@ fn install_loader_file(loader_path: &Path, args: &LoaderArgs) -> Result<()> { return Ok(()); } - confirm_or_bail( - &format!( - "loader.nu already exists at '{}'. Overwrite with the vendored copy?", - loader_path.display() - ), - args.yes, - "Loader install cancelled.", - )?; + assert_managed_file_owned(loader_path)?; + + if !args.force { + confirm_or_bail( + &format!( + "loader.nu already exists at '{}'. Overwrite with the vendored copy?", + loader_path.display() + ), + args.yes, + "Loader install cancelled.", + )?; + } } assert_not_symlink(loader_path, "loader.nu")?; From fb0ff235a62f10901aee3347a863bb0ff1f197ea Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Fri, 31 Jul 2026 22:42:09 -0700 Subject: [PATCH 03/61] fix: address PR #66 review feedback Fix remaining --version references in resolve.rs, tighten doctor_test assertion, add skip_path guard to legacy compat path, add negative tests for version+subcommand and legacy use_existing+skip_path. --- src/cmd/setup.rs | 11 +++++++---- src/core/resolve.rs | 10 +++++----- tests/doctor_test.rs | 13 ++++++++----- tests/setup_nu_test.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index f6b0a679..ad61cc55 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -172,7 +172,7 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { eprintln!("warning: --use-existing is deprecated, use 'numan setup nu use ' instead"); if args.skip_path { bail!( - "numan setup nu --use-existing cannot be combined with --skip-path. \ + "numan setup nu use cannot be combined with --skip-path. \ Off-PATH registration must persist the binary directory to PATH." ); } @@ -212,11 +212,14 @@ fn execute_use_path(yes: bool, root: &Path) -> Result<()> { let managed_dir = bootstrap::managed_nu_dir(root); if managed_dir.is_dir() { - let resolved_path_nu = path_nu + let resolved_path_nu = Path::new(&path_nu) .canonicalize() - .with_context(|| format!("Failed to resolve PATH Nu '{}'", path_nu.display()))?; + .with_context(|| format!("Failed to resolve PATH Nu '{}'", path_nu))?; let resolved_managed_dir = managed_dir.canonicalize().with_context(|| { - format!("Failed to resolve managed Nushell directory '{}'", managed_dir.display()) + format!( + "Failed to resolve managed Nushell directory '{}'", + managed_dir.display() + ) })?; if resolved_path_nu.starts_with(&resolved_managed_dir) { bail!( diff --git a/src/core/resolve.rs b/src/core/resolve.rs index ec2bffd9..62d338dc 100644 --- a/src/core/resolve.rs +++ b/src/core/resolve.rs @@ -40,7 +40,7 @@ impl Incompatibility { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PackageIncompatibility { pub issue: Incompatibility, - /// Suggested managed Nu pin (`setup nu --version`), when one can be derived. + /// Suggested managed Nu pin (`setup nu `), when one can be derived. pub suggested_pin: Option, pub available_versions: Vec, } @@ -314,7 +314,7 @@ fn append_nu_pin_options(msg: &mut String, package: &Package, diagnosis: &Packag /// Shared Nu-mismatch remediation for resolve / resolve_exact. /// -/// Keeps `setup nu --version`, states PATH is untouched, and closes with +/// Keeps `setup nu `, states PATH is untouched, and closes with /// "nothing was installed". Never suggests `registry sync` as an ABI fix. fn append_nu_mismatch_remediation(msg: &mut String, package: &Package, pin: Option<&str>) { if matches!(package.package_type, PackageType::Plugin) { @@ -326,7 +326,7 @@ fn append_nu_mismatch_remediation(msg: &mut String, package: &Package, pin: Opti msg.push_str("\n Options:"); if let Some(pin) = pin { msg.push_str(&format!( - "\n - Install a matching managed Nu: numan setup nu --version {pin}\n (installs under your Numan root; your PATH Nu is not touched)" + "\n - Install a matching managed Nu: numan setup nu {pin}\n (installs under your Numan root; your PATH Nu is not touched)" )); msg.push_str(&format!( "\n - Then: {}", @@ -573,7 +573,7 @@ mod tests { let pkg = test_plugin(); let err = resolver.resolve(&pkg).unwrap_err().to_string(); assert!(err.contains("too new"), "{err}"); - assert!(err.contains("setup nu --version 0.113.1"), "{err}"); + assert!(err.contains("setup nu 0.113.1"), "{err}"); assert!( err.contains("PATH Nu is not touched"), "expected PATH-untouched note: {err}" @@ -605,7 +605,7 @@ mod tests { .resolve_exact(&pkg, &target) .unwrap_err() .to_string(); - assert!(err.contains("setup nu --version 0.113.1"), "{err}"); + assert!(err.contains("setup nu 0.113.1"), "{err}"); assert!(err.contains("PATH Nu is not touched"), "{err}"); assert!( err.contains("numan search "), diff --git a/tests/doctor_test.rs b/tests/doctor_test.rs index 13cb4a97..1f1b960e 100644 --- a/tests/doctor_test.rs +++ b/tests/doctor_test.rs @@ -34,12 +34,15 @@ fn nu_setup_repair_test( ) -> anyhow::Result<()> { let expected = TEST_OFF_PATH.lock().unwrap(); // The doctor passes the off-path binary via NuSetupArgs::use_existing(), - // which sets action = Some(NuAction::Use { path }). - let actual = match &args.action { - Some(numan_cli::cmd::setup::NuAction::Use { path }) => Some(path.as_path()), - _ => args.use_existing.as_deref(), + // which sets action = Some(NuAction::Use { path }) and leaves use_existing unset. + let Some(numan_cli::cmd::setup::NuAction::Use { path }) = &args.action else { + panic!("expected NuAction::Use, got {:?}", args.action); }; - assert_eq!(actual, expected.as_ref().map(|p| p.as_path())); + assert_eq!(Some(path.as_path()), expected.as_ref().map(|p| p.as_path())); + assert!( + args.use_existing.is_none(), + "doctor must not use the deprecated flag" + ); assert!(args.yes); *TEST_NU_SETUP_CALLED.lock().unwrap() = true; Ok(()) diff --git a/tests/setup_nu_test.rs b/tests/setup_nu_test.rs index ccc5d4bf..f6be877a 100644 --- a/tests/setup_nu_test.rs +++ b/tests/setup_nu_test.rs @@ -249,3 +249,29 @@ fn setup_nu_rejects_use_existing_with_skip_path() { "unexpected error: {err}" ); } + +#[test] +fn cli_parse_rejects_version_with_subcommand() { + let full = ["numan", "setup", "nu", "remove", "0.113.1"]; + assert!( + Cli::try_parse_from(full).is_err(), + "a version must not be accepted alongside an action subcommand" + ); +} + +#[test] +fn setup_nu_rejects_legacy_use_existing_with_skip_path() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let existing = root.join("nu"); + std::fs::write(&existing, b"fake nu").unwrap(); + + let mut args = NuSetupArgs::install(None, false, true, true); + args.use_existing = Some(existing); + + let err = execute_nu(&args, root).unwrap_err(); + assert!( + err.to_string().contains("--skip-path"), + "unexpected error: {err}" + ); +} From 438d80b62356cb7495ee40f838dd7c12453e41ac Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sat, 1 Aug 2026 01:49:14 -0700 Subject: [PATCH 04/61] fix: clarify PATH messaging in Nu setup hints Change 'your PATH Nu is not touched' to 'your existing Nu is not replaced' since setup nu does modify PATH by default. --- src/cmd/try_cmd.rs | 2 +- src/core/resolve.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cmd/try_cmd.rs b/src/cmd/try_cmd.rs index cd727729..26d913e0 100644 --- a/src/cmd/try_cmd.rs +++ b/src/cmd/try_cmd.rs @@ -257,7 +257,7 @@ fn format_no_compatible_starter(nu: &str, triple: &str, pin: Option<&str>) -> St let mut msg = format!("No compatible starter package for Nu {nu} on {triple}."); if let Some(pin) = pin { msg.push_str(&format!( - "\nInstall a matching managed Nu: {} (PATH Nu is not touched), then retry `numan try`.", + "\nInstall a matching managed Nu: {} (your existing Nu is not replaced), then retry `numan try`.", hints::setup_nu_version(pin) )); } diff --git a/src/core/resolve.rs b/src/core/resolve.rs index 62d338dc..262c15d4 100644 --- a/src/core/resolve.rs +++ b/src/core/resolve.rs @@ -326,7 +326,7 @@ fn append_nu_mismatch_remediation(msg: &mut String, package: &Package, pin: Opti msg.push_str("\n Options:"); if let Some(pin) = pin { msg.push_str(&format!( - "\n - Install a matching managed Nu: numan setup nu {pin}\n (installs under your Numan root; your PATH Nu is not touched)" + "\n - Install a matching managed Nu: numan setup nu {pin}\n (installs under your Numan root; your existing Nu is not replaced)" )); msg.push_str(&format!( "\n - Then: {}", @@ -344,7 +344,7 @@ fn append_nu_mismatch_remediation(msg: &mut String, package: &Package, pin: Opti hints::CMD_SETUP_NU )); msg.push_str( - "\n (managed install under your Numan root; your PATH Nu is not touched)", + "\n (managed install under your Numan root; your existing Nu is not replaced)", ); } msg.push_str( @@ -575,8 +575,8 @@ mod tests { assert!(err.contains("too new"), "{err}"); assert!(err.contains("setup nu 0.113.1"), "{err}"); assert!( - err.contains("PATH Nu is not touched"), - "expected PATH-untouched note: {err}" + err.contains("existing Nu is not replaced"), + "expected existing-Nu-untouched note: {err}" ); assert!( err.contains("Nothing was installed. No changes were made."), @@ -606,7 +606,7 @@ mod tests { .unwrap_err() .to_string(); assert!(err.contains("setup nu 0.113.1"), "{err}"); - assert!(err.contains("PATH Nu is not touched"), "{err}"); + assert!(err.contains("existing Nu is not replaced"), "{err}"); assert!( err.contains("numan search "), "expected alternate-package guidance: {err}" @@ -645,7 +645,7 @@ mod tests { !err.contains("setup nu --version"), "must not invent a concrete pin: {err}" ); - assert!(err.contains("PATH Nu is not touched"), "{err}"); + assert!(err.contains("existing Nu is not replaced"), "{err}"); assert!( err.contains("numan search "), "expected alternate-package guidance: {err}" From 7e726a221f2530ccc62ca8b26c9a7063305ba2a9 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sat, 1 Aug 2026 07:02:44 -0700 Subject: [PATCH 05/61] Add reserved `numan use` CLI stub Introduce a new `numan use ` command path (`cli`, `main`, and `cmd::use_cmd`) as a post-1.0 placeholder that currently fails with a clear guidance message to use `numan setup nu `. Update roadmap/docs to reflect the consolidated plan filename, add post-1.0 side-by-side Nu management notes, and register the new command module in AGENTS metadata. --- AGENTS.md | 1 + CLAUDE.md | 154 ++++++++++++++++++ docs/plans/2026-07-29-remaining-roadmap.md | 2 +- ....md => consolidated-multi-repo-roadmap.md} | 53 +++++- docs/plans/handoff-promote-0114-plugins.md | 2 +- src/cli.rs | 2 + src/cmd/mod.rs | 1 + src/cmd/use_cmd.rs | 24 +++ src/main.rs | 1 + 9 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 CLAUDE.md rename docs/plans/{2026-07-30-consolidated-multi-repo-roadmap.md => consolidated-multi-repo-roadmap.md} (89%) create mode 100644 src/cmd/use_cmd.rs diff --git a/AGENTS.md b/AGENTS.md index 5568be42..0cc96e5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ src/ completions.rs — `numan completions `: bash/fish/zsh/powershell/nushell scripts (Phase 7.3) setup.rs — `numan setup nu [VERSION]|remove|path|use ` + `setup loader`: Nushell bootstrap + nushell-loader install try_cmd.rs — `numan try [--yes] [--no-activate]`: curated starter install + activate for current Nu + use_cmd.rs — `numan use `: reserved stub for side-by-side Nu version management (post-1.0) nu_pin_offer.rs — Shared TTY offer to `setup nu ` + `init --refresh` on Nu mismatch install/ download.rs — HTTP download with progress diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..498a565a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,154 @@ +You are Claude Code, Anthropic's official CLI for Claude. You are an interactive +software-engineering agent. The user works with you through a terminal; your text +output is what they see, and your tool calls are what change the world. + +# Tool selection (read this before every tool call on a code file) + +This project uses Serena, an MCP server that exposes semantic, symbol-aware tools +for reading and editing code. Serena's tools are the PRIMARY tools for code work +in this project. The built-in Read, Glob, Grep, and Edit tools are SECONDARY and +must not be used on code files when a Serena equivalent exists. + +The built-in tool descriptions in your context will tell you things like "use Read +for a known path" and "prefer dedicated tools (Read, Edit, Write, Glob, Grep)". +Those descriptions are written for projects without Serena and are SUPERSEDED here. +When they conflict with this section, this section wins. Do not rationalize the +built-in tools with "the file is small," "I already know what I need," "this is +one call versus three," or "the path is known" — those rationalizations have +produced incorrect behavior before and are explicitly disallowed. + +## Mapping (use the right column, not the left) + +Task Tool to use +-------------------------------------- ---------------------------------------- +See a code file's structure get_symbols_overview +Read a specific symbol's body find_symbol (include_body=true) +Find a symbol by name across the repo find_symbol +Find references / callers find_referencing_symbols +Find declarations / implementations find_declaration / _find_implementations +Edit a symbol's body replace_symbol_body +Insert near a symbol insert_before_symbol / _insert_after_symbol +Pattern replace inside a file replace_content +Rename / move / delete a symbol rename / _move / _safe_delete +Inline a symbol inline_symbol +Type hierarchy type_hierarchy + +Built-in Read/Edit/Glob/Grep are permitted on code files ONLY when: +- Serena has been tried on the target and failed, OR +- The file is not parseable as code (e.g., generated, malformed), OR +- You need a regex search across many files that Serena's symbolic tools cannot + express — in which case Grep is acceptable as a discovery step, but follow-up + reads/edits on matched code files must still go through Serena. +- You need to read a few lines and symbolic reads would be an overkill. +- You absolutely have to read the full file for some reason. + +Read/Edit/Glob are fine for non-code files: markdown, JSON, YAML, TOML, .env, +config files, lockfiles, plain text, images. + +## Required workflow before editing code + +1. get_symbols_overview on the target file (skip if already done this session). +2. find_symbol with include_body=true for the specific symbols you'll touch. + Read only the symbols you need — not the whole file. +3. Edit with replace_symbol_body, insert_before_symbol, insert_after_symbol, or + replace_content. Never use the built-in Edit on a code file when one of these + fits. + +## Self-check + +Before every Read, Glob, Grep, or Edit call: "Does this target a code file, and +does the mapping above name a Serena tool for this task?" If yes, switch. Do this +check every time — not just once per session. + +# Doing tasks + +The user will ask you to fix bugs, add features, refactor, explain code, and +similar. Approach each task with these defaults: + +- Understand before changing. Use the symbolic tools to build a precise picture of +what's there, then make the smallest change that satisfies the request. +- Don't add scope. No surrounding cleanup on a bug fix, no abstractions for +hypothetical future needs, no error handling for cases that can't happen, no +feature flags or backwards-compat shims unless asked. Three similar lines beats +a premature abstraction. +- Don't write comments unless the WHY is non-obvious — a hidden constraint, a +workaround, a subtle invariant. Don't narrate WHAT the code does; well-named +identifiers handle that. Don't reference the current task or PR in comments. +- Prefer editing existing files to creating new ones. Never create *.md or README +files unless the user explicitly asks. +- For exploratory questions ("what could we do about X?"), reply in 2–3 sentences +with a recommendation and the main tradeoff. Don't implement until the user +agrees. +- For UI/frontend changes you can't test in a browser, say so explicitly rather +than claiming success. +- Watch for security issues (injection, XSS, SQL injection, path traversal, secret +leaks). Fix them when you spot them. + +# Executing actions with care + +Local, reversible actions (editing files, running tests, reading state) are free +to take. Pause and confirm before: + +- Destructive ops: deleting files/branches, dropping tables, killing processes, +rm -rf, overwriting uncommitted changes, git reset --hard, force-push. +- Hard-to-reverse ops: amending published commits, removing dependencies, +modifying CI/CD. +- Externally visible actions: pushing, opening/closing/commenting on PRs or +issues, sending messages, posting to third-party services. +- Uploading content to third-party tools (renderers, pastebins) — assume it's +public and may be cached. + +If you hit an obstacle, find the root cause. Don't bypass it with --no-verify, +--force, or by deleting the thing in your way. If you find unfamiliar files, +branches, or config, investigate before deleting — it may be the user's +in-progress work. + +A user approving an action once does not approve it forever. Match the scope of +your action to what was actually requested. + +# Git and commits + +- Only commit when the user asks. Never proactively. +- Never update git config. Never skip hooks (--no-verify, --no-gpg-sign) unless +the user explicitly asks. +- Prefer new commits over --amend. If a pre-commit hook fails, the commit didn't +happen — fix the issue, re-stage, and create a NEW commit (not --amend, which +would modify the previous commit). +- Stage files by name, not `git add -A` or `git add .` — those can sweep in +secrets or large binaries. +- Don't commit files that look like secrets (.env, credentials.json, *.pem). If +the user explicitly asks, warn first. +- For commit messages, use a HEREDOC to preserve formatting. End the trailer with: +Co-Authored-By: Claude Opus 4.7 (1M context) +- Don't push unless asked. Never force-push to main/master; warn if asked. +- For PRs, use `gh` via Bash. Look at the full diff against the base branch (not +just the latest commit) before drafting title/body. + +# Tone and output + +- Your tool calls aren't visible to the user — only your text is. Before your +first tool call, say in one sentence what you're about to do. While working, +give short updates at key moments: a finding, a direction change, a blocker. +Brief is good; silent is not. +- Don't narrate internal deliberation. State results and decisions; skip the +thinking-aloud. +- End-of-turn summary: one or two sentences max. What changed, what's next. +Nothing else. +- Match response shape to the task: a simple question gets a direct answer, not +headers and sections. +- No emojis unless the user asks. +- Use Github-flavored markdown. Reference code locations as `path:line` so the +user can jump. + +# Parallel tool calls + +When tool calls don't depend on each other, issue them in a single response. +When they do depend on each other, issue them sequentially with the dependent +values resolved. Don't use placeholders or guess. + +# Asking for help vs. acting + +When a request is ambiguous in a way that materially changes the work, ask one +focused question. When it's only ambiguous in ways that don't change the work, +pick the reasonable interpretation and proceed — and say which interpretation +you picked. diff --git a/docs/plans/2026-07-29-remaining-roadmap.md b/docs/plans/2026-07-29-remaining-roadmap.md index 81399207..46745460 100644 --- a/docs/plans/2026-07-29-remaining-roadmap.md +++ b/docs/plans/2026-07-29-remaining-roadmap.md @@ -3,7 +3,7 @@ **Status date:** 2026-07-29 **Superseded:** Use the consolidated multi-repo plan -[`2026-07-30-consolidated-multi-repo-roadmap.md`](2026-07-30-consolidated-multi-repo-roadmap.md). +[`consolidated-multi-repo-roadmap.md`](consolidated-multi-repo-roadmap.md). This file is retained as the prior client draft for history. --- diff --git a/docs/plans/2026-07-30-consolidated-multi-repo-roadmap.md b/docs/plans/consolidated-multi-repo-roadmap.md similarity index 89% rename from docs/plans/2026-07-30-consolidated-multi-repo-roadmap.md rename to docs/plans/consolidated-multi-repo-roadmap.md index fc67a20b..1bfd675c 100644 --- a/docs/plans/2026-07-30-consolidated-multi-repo-roadmap.md +++ b/docs/plans/consolidated-multi-repo-roadmap.md @@ -1,7 +1,5 @@ # Numan Consolidated Multi-Repo Roadmap -**Status date:** 2026-07-31 - **Authority:** This is the single cross-repo plan for remaining work toward Numan 1.0. Repo-local roadmaps keep operational detail and should link here: @@ -287,9 +285,58 @@ Ship 1.0 when **all** of the following are true: --- +## Post-1.0 Features (0.2+) + +Captured from product discussion; subject to change. + +### Side-by-side Nu version management (`numan use`) + +**Vision:** Numan manages multiple Nu versions simultaneously. The user picks the +Nu version that has the plugins they need, and switching is instant. + +- `numan use ` — switch active Nu (auto-installs if missing) +- `numan use latest` — switch to newest installed version +- `numan use list` — show installed versions + active marker + per-version plugin counts +- Storage: `/tools/nushell//nu` (immutable, one dir per version) +- Active marker: symlink or marker file at `/tools/nushell/active` +- PATH/shim: Numan manages a shim directory on PATH pointing at the active version + +**Per-version activation sets:** + +- Lockfile `plugin_activation` becomes keyed by Nu version +- Switching Nu activates/deactivates plugins for that version automatically +- `numan use 0.113.1` → activates plugins compatible with 0.113, deactivates 0.114-only ones + +**Numan-level aliases (optional):** + +- `numan alias work 0.113.1` → `numan use work` switches to 0.113.1 +- Persisted in Numan config; survives shell changes +- Shell-level aliases (`alias nu113 = numan use 0.113.1`) remain a user option + +**Catalog implication:** backfilling older Nu versions (0.112, 0.113) becomes +valuable because each version is a switchable "profile" rather than a dead end. +Pre-0.112 plugins remain deferred unless product re-scopes. + +**Backfill data:** `numan-plugins/docs/backlog.json` (schema v1) tracks ALL +release versions per plugin with their Nu minor compatibility. The +`backfill_targets` field lists Nu minors that have eligible plugin versions not +yet in the registry. Use this to drive backfill waves once `numan use` ships. +Source: awesome-nu + manual discovery. Entries marked `NEEDS_RESEARCH` need +their version history filled in before promotion. + +**Command reserved:** `numan use` exists as a stub in 0.1.x (prints a +"coming in 0.2" message) so the UX shape is locked. + +### Explicitly NOT in scope for post-1.0 (requires Nu upstream) + +- Simultaneous multi-version plugin hosting (running 0.113 and 0.114 plugins in + one session) — requires Nu-core plugin protocol bridging / remote plugin hosts +- Plugin ABI translation layer between Nu versions + +--- + ## Explicitly Deferred (All Repos) -- Silent side-by-side Nu profile switching - Source builds hidden inside registry intake - Publishing registry entries without review - Calling packages "approved" or "audited" solely because they are in the official registry diff --git a/docs/plans/handoff-promote-0114-plugins.md b/docs/plans/handoff-promote-0114-plugins.md index 5ee9b7da..0d80d2df 100644 --- a/docs/plans/handoff-promote-0114-plugins.md +++ b/docs/plans/handoff-promote-0114-plugins.md @@ -157,7 +157,7 @@ Lifecycle-prove runs: `init → registry sync → search → info → install - Update `backlog.json`: set `blocker: "PROMOTED"`, add `c1_note` with date and details - Update `docs/intake-state.json` in numan-registry -- Update `d:\Dev\numan\docs\plans\2026-07-30-consolidated-multi-repo-roadmap.md` Wave 2 research queue checkboxes +- Update `d:\Dev\numan\docs\plans\consolidated-multi-repo-roadmap.md` Wave 2 research queue checkboxes ## Constraints & Gotchas diff --git a/src/cli.rs b/src/cli.rs index 5c6cae9f..5482d245 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,4 +61,6 @@ pub enum Commands { Setup(cmd::setup::SetupCommands), /// Install and activate a starter package that fits your Nu Try(cmd::try_cmd::TryArgs), + /// Switch the active managed Nu version (reserved, post-1.0) + Use(cmd::use_cmd::UseArgs), } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 740dd804..13855839 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -17,6 +17,7 @@ pub mod setup; pub mod snapshot; pub mod try_cmd; pub mod update; +pub mod use_cmd; use crate::state::autoload_recovery::AutoloadRecoveryOutcome; diff --git a/src/cmd/use_cmd.rs b/src/cmd/use_cmd.rs new file mode 100644 index 00000000..5ae59f31 --- /dev/null +++ b/src/cmd/use_cmd.rs @@ -0,0 +1,24 @@ +//! `numan use` — switch the active managed Nu version (reserved, post-1.0). +//! +//! This command is reserved for side-by-side Nu version management. +//! See `docs/plans/consolidated-multi-repo-roadmap.md` § Post-1.0. + +use anyhow::{bail, Result}; +use clap::Args; +use std::path::Path; + +#[derive(Args, Debug)] +pub struct UseArgs { + /// Nu version to switch to (e.g. 0.113.1), or "latest" + #[arg(required = true)] + pub version: String, +} + +pub fn execute(args: &UseArgs, _root: &Path) -> Result<()> { + bail!( + "'numan use {}' is reserved for side-by-side Nu version management (planned for 0.2).\n\ + For now, use 'numan setup nu {}' to install a specific Nu version.", + args.version, + args.version, + ) +} diff --git a/src/main.rs b/src/main.rs index 8c29223a..cf1fa76a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,5 +43,6 @@ fn main() -> anyhow::Result<()> { } Commands::Setup(cmd) => numan_cli::cmd::setup::execute(cmd, &root), Commands::Try(args) => numan_cli::cmd::try_cmd::execute(&args, &root), + Commands::Use(args) => numan_cli::cmd::use_cmd::execute(&args, &root), } } From 79517970ce4d68f9a85b2473cb55f45f0911b383 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:14:45 +0000 Subject: [PATCH 06/61] fix: apply CodeRabbit auto-fixes Fixed 4 file(s) based on 5 unresolved review comments. Co-authored-by: CodeRabbit --- CLAUDE.md | 2 ++ docs/plans/consolidated-multi-repo-roadmap.md | 4 ++-- src/cmd/use_cmd.rs | 12 +++++++++--- src/main.rs | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 498a565a..34224946 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,5 @@ +# Claude Code Agent Instructions + You are Claude Code, Anthropic's official CLI for Claude. You are an interactive software-engineering agent. The user works with you through a terminal; your text output is what they see, and your tool calls are what change the world. diff --git a/docs/plans/consolidated-multi-repo-roadmap.md b/docs/plans/consolidated-multi-repo-roadmap.md index 1bfd675c..6a49ee7c 100644 --- a/docs/plans/consolidated-multi-repo-roadmap.md +++ b/docs/plans/consolidated-multi-repo-roadmap.md @@ -285,7 +285,7 @@ Ship 1.0 when **all** of the following are true: --- -## Post-1.0 Features (0.2+) +## Post-1.0 Features Captured from product discussion; subject to change. @@ -325,7 +325,7 @@ Source: awesome-nu + manual discovery. Entries marked `NEEDS_RESEARCH` need their version history filled in before promotion. **Command reserved:** `numan use` exists as a stub in 0.1.x (prints a -"coming in 0.2" message) so the UX shape is locked. +"planned post-1.0" message) so the UX shape is locked. ### Explicitly NOT in scope for post-1.0 (requires Nu upstream) diff --git a/src/cmd/use_cmd.rs b/src/cmd/use_cmd.rs index 5ae59f31..b546db6b 100644 --- a/src/cmd/use_cmd.rs +++ b/src/cmd/use_cmd.rs @@ -15,10 +15,16 @@ pub struct UseArgs { } pub fn execute(args: &UseArgs, _root: &Path) -> Result<()> { + let setup_command = if args.version == "latest" { + "numan setup nu".to_string() + } else { + format!("numan setup nu {}", args.version) + }; + bail!( - "'numan use {}' is reserved for side-by-side Nu version management (planned for 0.2).\n\ - For now, use 'numan setup nu {}' to install a specific Nu version.", - args.version, + "'numan use {}' is reserved for side-by-side Nu version management (planned post-1.0).\n\ + For now, use '{}' to install a specific Nu version.", args.version, + setup_command, ) } diff --git a/src/main.rs b/src/main.rs index cf1fa76a..7b122a9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,10 +11,10 @@ fn main() -> anyhow::Result<()> { .root .unwrap_or_else(|| config::Config::resolve_root(&platform)); - // Ensure root directory exists (completions/doctor/setup do not need layout) + // Ensure root directory exists (completions/doctor/setup/use do not need layout) if !matches!( cli.command, - Commands::Completions(_) | Commands::Doctor(_) | Commands::Setup(_) + Commands::Completions(_) | Commands::Doctor(_) | Commands::Setup(_) | Commands::Use(_) ) { std::fs::create_dir_all(&root)?; } From 8062a11ebc4bd26546e99b000e5f9cb0981235e7 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sat, 1 Aug 2026 08:32:55 -0700 Subject: [PATCH 07/61] feat: implement numan use for side-by-side Nu version management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/nu/version_manager.rs for active version tracking - Active version stored in /nu_state/active-version.json - Installed versions discovered from /tools/nushell// - Helpers: read/write active version, list installed, check if installed - Migration logic for legacy single-binary installs - Update bootstrap.rs to install to versioned subdirectories - install_from_archive() now writes to /tools/nushell// - managed_nu_binary() delegates to version_manager for active version - Automatically sets newly installed version as active - Implement numan use command - numan use — switch to specific installed version - numan use latest — switch to newest installed version - numan use list — show all installed versions with active marker - Validates version is installed before switching - Provides helpful hints when version not found - Auto-migrates legacy single-binary installs on first run - Remove Commands::Use from root init exclusion (now needs root) All 425 tests pass, clippy clean, fmt applied. --- src/cmd/use_cmd.rs | 205 +++++++++++++++++++++--- src/main.rs | 4 +- src/nu/bootstrap.rs | 17 +- src/nu/mod.rs | 1 + src/nu/version_manager.rs | 317 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 522 insertions(+), 22 deletions(-) create mode 100644 src/nu/version_manager.rs diff --git a/src/cmd/use_cmd.rs b/src/cmd/use_cmd.rs index b546db6b..35798f6f 100644 --- a/src/cmd/use_cmd.rs +++ b/src/cmd/use_cmd.rs @@ -1,30 +1,199 @@ -//! `numan use` — switch the active managed Nu version (reserved, post-1.0). +//! `numan use` — switch the active managed Nu version. //! -//! This command is reserved for side-by-side Nu version management. -//! See `docs/plans/consolidated-multi-repo-roadmap.md` § Post-1.0. +//! Supports side-by-side Nu version management: +//! - `numan use ` — switch to a specific installed version +//! - `numan use latest` — switch to the newest installed version +//! - `numan use list` — show all installed versions with active marker -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::Args; use std::path::Path; +use crate::nu::version_manager; + #[derive(Args, Debug)] pub struct UseArgs { - /// Nu version to switch to (e.g. 0.113.1), or "latest" + /// Nu version to switch to (e.g. 0.113.1), "latest", or "list" #[arg(required = true)] pub version: String, } -pub fn execute(args: &UseArgs, _root: &Path) -> Result<()> { - let setup_command = if args.version == "latest" { - "numan setup nu".to_string() - } else { - format!("numan setup nu {}", args.version) - }; - - bail!( - "'numan use {}' is reserved for side-by-side Nu version management (planned post-1.0).\n\ - For now, use '{}' to install a specific Nu version.", - args.version, - setup_command, - ) +pub fn execute(args: &UseArgs, root: &Path) -> Result<()> { + // Attempt migration of legacy single-binary install before any operation. + // This is a no-op if migration has already occurred or no legacy install exists. + let _ = version_manager::migrate_legacy_install(root); + + match args.version.as_str() { + "list" => execute_list(root), + "latest" => execute_latest(root), + version => execute_switch(root, version), + } +} + +/// List all installed Nu versions, marking the active one. +fn execute_list(root: &Path) -> Result<()> { + let versions = version_manager::list_installed_versions(root)?; + let active = version_manager::read_active_version(root)?; + + if versions.is_empty() { + println!("No Nu versions installed."); + println!("Run 'numan setup nu' or 'numan setup nu --version ' to install."); + return Ok(()); + } + + println!("Installed Nu versions:"); + for version in versions { + let is_active = active.as_ref().is_some_and(|a| a.version == version); + let marker = if is_active { " (active)" } else { "" }; + println!(" {}{}", version, marker); + } + + Ok(()) +} + +/// Switch to the latest (newest) installed Nu version. +fn execute_latest(root: &Path) -> Result<()> { + let latest = version_manager::latest_installed_version(root)?; + match latest { + Some(version) => { + version_manager::write_active_version(root, &version)?; + println!("Switched to Nu {} (latest installed).", version); + Ok(()) + } + None => { + bail!( + "No Nu versions installed.\n\ + Run 'numan setup nu' or 'numan setup nu --version ' first." + ) + } + } +} + +/// Switch to a specific Nu version. +fn execute_switch(root: &Path, version: &str) -> Result<()> { + // Validate the version is installed. + if !version_manager::is_version_installed(root, version) { + let installed = version_manager::list_installed_versions(root)?; + let hint = if installed.is_empty() { + format!( + "No Nu versions installed.\n\ + Run 'numan setup nu --version {}' to install.", + version + ) + } else { + format!( + "Nu {} is not installed.\n\ + Installed versions: {}\n\ + Run 'numan setup nu --version {}' to install, or 'numan use list' to see available versions.", + version, + installed.join(", "), + version + ) + }; + bail!("{}", hint); + } + + // Switch to the requested version. + version_manager::write_active_version(root, version) + .with_context(|| format!("Failed to switch to Nu {}", version))?; + println!("Switched to Nu {}.", version); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_fake_version(root: &Path, version: &str) { + let binary_name = if cfg!(windows) { "nu.exe" } else { "nu" }; + let dir = version_manager::version_install_dir(root, version); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(binary_name), "fake").unwrap(); + } + + #[test] + fn test_use_list_empty() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let args = UseArgs { + version: "list".to_string(), + }; + // Should not error, just print "no versions installed". + execute(&args, root).unwrap(); + } + + #[test] + fn test_use_list_with_versions() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + create_fake_version(root, "0.112.0"); + create_fake_version(root, "0.113.1"); + version_manager::write_active_version(root, "0.113.1").unwrap(); + + let args = UseArgs { + version: "list".to_string(), + }; + execute(&args, root).unwrap(); + } + + #[test] + fn test_use_latest_no_versions() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let args = UseArgs { + version: "latest".to_string(), + }; + let result = execute(&args, root); + assert!(result.is_err()); + } + + #[test] + fn test_use_latest_with_versions() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + create_fake_version(root, "0.112.0"); + create_fake_version(root, "0.113.1"); + + let args = UseArgs { + version: "latest".to_string(), + }; + execute(&args, root).unwrap(); + + let active = version_manager::read_active_version(root).unwrap().unwrap(); + assert_eq!(active.version, "0.113.1"); + } + + #[test] + fn test_use_switch_not_installed() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let args = UseArgs { + version: "0.113.1".to_string(), + }; + let result = execute(&args, root); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + // Error message should mention that no versions are installed or the version is not installed. + assert!( + err_msg.contains("installed"), + "Error message was: {}", + err_msg + ); + } + + #[test] + fn test_use_switch_success() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + create_fake_version(root, "0.113.1"); + + let args = UseArgs { + version: "0.113.1".to_string(), + }; + execute(&args, root).unwrap(); + + let active = version_manager::read_active_version(root).unwrap().unwrap(); + assert_eq!(active.version, "0.113.1"); + } } diff --git a/src/main.rs b/src/main.rs index 7b122a9f..cf1fa76a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,10 +11,10 @@ fn main() -> anyhow::Result<()> { .root .unwrap_or_else(|| config::Config::resolve_root(&platform)); - // Ensure root directory exists (completions/doctor/setup/use do not need layout) + // Ensure root directory exists (completions/doctor/setup do not need layout) if !matches!( cli.command, - Commands::Completions(_) | Commands::Doctor(_) | Commands::Setup(_) | Commands::Use(_) + Commands::Completions(_) | Commands::Doctor(_) | Commands::Setup(_) ) { std::fs::create_dir_all(&root)?; } diff --git a/src/nu/bootstrap.rs b/src/nu/bootstrap.rs index 4a630452..b9e334f8 100644 --- a/src/nu/bootstrap.rs +++ b/src/nu/bootstrap.rs @@ -38,7 +38,16 @@ pub fn managed_nu_dir(root: &Path) -> PathBuf { root.join("tools").join("nushell") } +/// Returns the path to the currently active Nu binary. +/// +/// This delegates to `version_manager::active_nu_binary()` which reads the active +/// version marker and returns the versioned binary path. pub fn managed_nu_binary(root: &Path) -> PathBuf { + // Try to read the active version; if set, return the versioned binary path. + if let Ok(Some(binary)) = crate::nu::version_manager::active_nu_binary(root) { + return binary; + } + // Fallback to legacy path (for backward compat or when no active version is set). managed_nu_dir(root).join(nu_binary_name()) } @@ -212,9 +221,10 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) -> .with_context(|| format!("Failed to extract '{}'", archive_path.display()))?; let source = locate_extracted_nu_binary(&extract_root)?; - let dest_dir = managed_nu_dir(root); + // Install to versioned subdirectory: /tools/nushell// + let dest_dir = crate::nu::version_manager::version_install_dir(root, version); std::fs::create_dir_all(&dest_dir)?; - let dest = managed_nu_binary(root); + let dest = crate::nu::version_manager::version_binary(root, version); std::fs::copy(&source, &dest).with_context(|| { format!( @@ -224,7 +234,10 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) -> ) })?; make_executable(&dest)?; + // Also write a VERSION file in the version directory for reference. std::fs::write(dest_dir.join("VERSION"), version.as_bytes())?; + // Set as the active version. + crate::nu::version_manager::write_active_version(root, version)?; Ok(dest) } diff --git a/src/nu/mod.rs b/src/nu/mod.rs index 216ea8f8..da5a43da 100644 --- a/src/nu/mod.rs +++ b/src/nu/mod.rs @@ -1,3 +1,4 @@ pub mod autoload; pub mod bootstrap; pub mod paths; +pub mod version_manager; diff --git a/src/nu/version_manager.rs b/src/nu/version_manager.rs new file mode 100644 index 00000000..029d9a4c --- /dev/null +++ b/src/nu/version_manager.rs @@ -0,0 +1,317 @@ +//! Active Nu version management for side-by-side installs. +//! +//! Numan supports multiple Nu versions installed under `/tools/nushell//`. +//! The "active" version is tracked in `/nu_state/active-version.json` and determines +//! which Nu binary is used for plugin registration, module autoload, and other operations. + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::util::atomic::write_json_atomic; + +/// Active version marker file location. +fn active_version_path(root: &Path) -> PathBuf { + root.join("nu_state").join("active-version.json") +} + +/// Active version marker contents. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActiveVersion { + /// The currently active Nu version (e.g., "0.113.1"). + pub version: String, +} + +/// Read the active Nu version from the marker file. +/// +/// Returns `None` if no active version is set (marker doesn't exist or is invalid). +pub fn read_active_version(root: &Path) -> Result> { + let path = active_version_path(root); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read active version from '{}'", path.display()))?; + let active: ActiveVersion = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse active version from '{}'", path.display()))?; + Ok(Some(active)) +} + +/// Write the active Nu version to the marker file. +pub fn write_active_version(root: &Path, version: &str) -> Result<()> { + let path = active_version_path(root); + // Ensure parent directory exists. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create nu_state directory '{}'", parent.display()) + })?; + } + let active = ActiveVersion { + version: version.to_string(), + }; + write_json_atomic(&path, &active) + .with_context(|| format!("Failed to write active version to '{}'", path.display()))?; + Ok(()) +} + +/// Directory where versioned Nu installs live. +pub fn versioned_nu_dir(root: &Path) -> PathBuf { + root.join("tools").join("nushell") +} + +/// Directory for a specific Nu version install. +pub fn version_install_dir(root: &Path, version: &str) -> PathBuf { + versioned_nu_dir(root).join(version) +} + +/// Binary path for a specific Nu version. +pub fn version_binary(root: &Path, version: &str) -> PathBuf { + let binary_name = if cfg!(windows) { "nu.exe" } else { "nu" }; + version_install_dir(root, version).join(binary_name) +} + +/// Binary path for the currently active Nu version. +/// +/// Returns `None` if no active version is set or the active version's binary doesn't exist. +pub fn active_nu_binary(root: &Path) -> Result> { + let Some(active) = read_active_version(root)? else { + return Ok(None); + }; + let binary = version_binary(root, &active.version); + if binary.exists() { + Ok(Some(binary)) + } else { + Ok(None) + } +} + +/// List all installed Nu versions (by scanning version directories). +pub fn list_installed_versions(root: &Path) -> Result> { + let dir = versioned_nu_dir(root); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut versions = Vec::new(); + for entry in std::fs::read_dir(&dir) + .with_context(|| format!("Failed to read Nu versions directory '{}'", dir.display()))? + { + let entry = entry?; + if entry.file_type()?.is_dir() { + if let Some(name) = entry.file_name().to_str() { + // Check if this directory contains a Nu binary. + let binary_name = if cfg!(windows) { "nu.exe" } else { "nu" }; + if entry.path().join(binary_name).exists() { + versions.push(name.to_string()); + } + } + } + } + // Sort by semver descending (newest first). + versions.sort_by(|a, b| { + let a_parsed = semver::Version::parse(a); + let b_parsed = semver::Version::parse(b); + match (a_parsed, b_parsed) { + (Ok(a_ver), Ok(b_ver)) => b_ver.cmp(&a_ver), + _ => b.cmp(a), + } + }); + Ok(versions) +} + +/// Check if a specific Nu version is installed. +pub fn is_version_installed(root: &Path, version: &str) -> bool { + version_binary(root, version).exists() +} + +/// Get the latest installed Nu version, or `None` if no versions are installed. +pub fn latest_installed_version(root: &Path) -> Result> { + let versions = list_installed_versions(root)?; + Ok(versions.into_iter().next()) +} + +/// Migrate a legacy single-binary install to the versioned structure. +/// +/// If `/tools/nushell/nu` exists but no versioned directories exist, +/// detect its version and move it to `/tools/nushell//nu`. +/// +/// Returns `Ok(true)` if migration occurred, `Ok(false)` otherwise. +pub fn migrate_legacy_install(root: &Path) -> Result { + let legacy_binary = if cfg!(windows) { + root.join("tools").join("nushell").join("nu.exe") + } else { + root.join("tools").join("nushell").join("nu") + }; + + // If legacy binary doesn't exist, nothing to migrate. + if !legacy_binary.exists() { + return Ok(false); + } + + // If versioned directories already exist, don't migrate (avoid conflicts). + let versioned_dir = versioned_nu_dir(root); + if versioned_dir.exists() { + for entry in std::fs::read_dir(&versioned_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + // At least one versioned dir exists; skip migration. + return Ok(false); + } + } + } + + // Detect the version of the legacy binary. + let output = std::process::Command::new(&legacy_binary) + .arg("--version") + .output() + .with_context(|| { + format!( + "Failed to execute legacy Nu binary at '{}'", + legacy_binary.display() + ) + })?; + + if !output.status.success() { + bail!( + "Legacy Nu binary at '{}' exited with status {}", + legacy_binary.display(), + output.status + ); + } + + let version_output = String::from_utf8_lossy(&output.stdout); + // Parse version from output like "Nushell 0.113.1 (abc123)" or "0.113.1". + let version = parse_nu_version_from_output(&version_output)?; + + // Move the legacy binary to the versioned directory. + let version_dir = version_install_dir(root, &version); + std::fs::create_dir_all(&version_dir).with_context(|| { + format!( + "Failed to create version directory '{}'", + version_dir.display() + ) + })?; + + let new_binary = version_binary(root, &version); + std::fs::rename(&legacy_binary, &new_binary).with_context(|| { + format!( + "Failed to move '{}' to '{}'", + legacy_binary.display(), + new_binary.display() + ) + })?; + + // Set as active version. + write_active_version(root, &version)?; + + // Try to remove the now-empty legacy directory (ignore errors if not empty). + let _ = std::fs::remove_dir(legacy_binary.parent().unwrap()); + + Ok(true) +} + +/// Parse a Nu version string from `nu --version` output. +/// +/// Handles formats like: +/// - "Nushell 0.113.1 (abc123)" +/// - "0.113.1" +/// - "0.113.1\n" +fn parse_nu_version_from_output(output: &str) -> Result { + let trimmed = output.trim(); + // Try to extract version from "Nushell X.Y.Z" format. + if let Some(rest) = trimmed.strip_prefix("Nushell ") { + // Take the first whitespace-delimited token. + if let Some(version) = rest.split_whitespace().next() { + // Validate it looks like a semver. + if semver::Version::parse(version).is_ok() { + return Ok(version.to_string()); + } + } + } + // Try parsing the whole thing as a semver. + if semver::Version::parse(trimmed).is_ok() { + return Ok(trimmed.to_string()); + } + bail!("Failed to parse Nu version from output: '{}'", trimmed) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_active_version_roundtrip() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Initially no active version. + assert!(read_active_version(root).unwrap().is_none()); + + // Write and read back. + write_active_version(root, "0.113.1").unwrap(); + let active = read_active_version(root).unwrap().unwrap(); + assert_eq!(active.version, "0.113.1"); + + // Overwrite. + write_active_version(root, "0.114.0").unwrap(); + let active = read_active_version(root).unwrap().unwrap(); + assert_eq!(active.version, "0.114.0"); + } + + #[test] + fn test_list_installed_versions_empty() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let versions = list_installed_versions(root).unwrap(); + assert!(versions.is_empty()); + } + + #[test] + fn test_list_installed_versions_with_versions() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Create versioned directories with fake binaries. + let binary_name = if cfg!(windows) { "nu.exe" } else { "nu" }; + for version in &["0.112.0", "0.113.1", "0.114.0"] { + let dir = version_install_dir(root, version); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(binary_name), "fake").unwrap(); + } + + let versions = list_installed_versions(root).unwrap(); + // Should be sorted descending. + assert_eq!(versions, vec!["0.114.0", "0.113.1", "0.112.0"]); + } + + #[test] + fn test_is_version_installed() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + assert!(!is_version_installed(root, "0.113.1")); + + // Create the version directory with a binary. + let binary_name = if cfg!(windows) { "nu.exe" } else { "nu" }; + let dir = version_install_dir(root, "0.113.1"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(binary_name), "fake").unwrap(); + + assert!(is_version_installed(root, "0.113.1")); + assert!(!is_version_installed(root, "0.114.0")); + } + + #[test] + fn test_parse_nu_version_from_output() { + assert_eq!( + parse_nu_version_from_output("Nushell 0.113.1 (abc123)").unwrap(), + "0.113.1" + ); + assert_eq!(parse_nu_version_from_output("0.113.1").unwrap(), "0.113.1"); + assert_eq!( + parse_nu_version_from_output("0.113.1\n").unwrap(), + "0.113.1" + ); + assert!(parse_nu_version_from_output("invalid").is_err()); + } +} From 213e8740cc5985c8c83ce5712dab52e24e84770a Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:41:01 +0000 Subject: [PATCH 08/61] =?UTF-8?q?fix:=205=20findings=20=E2=80=94=20Validat?= =?UTF-8?q?e=20and=20normalize=20Nu=20versions;=20Propagate=20legacy=20m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate and normalize Nu versions - Propagate legacy migration errors - Handle invalid active markers gracefully - Avoid parent path panic - Update Nu setup guidance --- src/cmd/use_cmd.rs | 14 +++++++++----- src/nu/version_manager.rs | 30 +++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/cmd/use_cmd.rs b/src/cmd/use_cmd.rs index 35798f6f..462c40fb 100644 --- a/src/cmd/use_cmd.rs +++ b/src/cmd/use_cmd.rs @@ -6,6 +6,8 @@ //! - `numan use list` — show all installed versions with active marker use anyhow::{bail, Context, Result}; + +use crate::util::hints; use clap::Args; use std::path::Path; @@ -21,7 +23,8 @@ pub struct UseArgs { pub fn execute(args: &UseArgs, root: &Path) -> Result<()> { // Attempt migration of legacy single-binary install before any operation. // This is a no-op if migration has already occurred or no legacy install exists. - let _ = version_manager::migrate_legacy_install(root); + version_manager::migrate_legacy_install(root) + .with_context(|| "Failed to migrate legacy Nu installation")?; match args.version.as_str() { "list" => execute_list(root), @@ -37,7 +40,7 @@ fn execute_list(root: &Path) -> Result<()> { if versions.is_empty() { println!("No Nu versions installed."); - println!("Run 'numan setup nu' or 'numan setup nu --version ' to install."); + println!("Run 'numan setup nu' or 'numan setup nu ' to install."); return Ok(()); } @@ -63,7 +66,7 @@ fn execute_latest(root: &Path) -> Result<()> { None => { bail!( "No Nu versions installed.\n\ - Run 'numan setup nu' or 'numan setup nu --version ' first." + Run 'numan setup nu' or 'numan setup nu ' first." ) } } @@ -71,20 +74,21 @@ fn execute_latest(root: &Path) -> Result<()> { /// Switch to a specific Nu version. fn execute_switch(root: &Path, version: &str) -> Result<()> { + let version = version_manager::normalize_version(version)?; // Validate the version is installed. if !version_manager::is_version_installed(root, version) { let installed = version_manager::list_installed_versions(root)?; let hint = if installed.is_empty() { format!( "No Nu versions installed.\n\ - Run 'numan setup nu --version {}' to install.", + Run 'numan setup nu {}' to install.", version ) } else { format!( "Nu {} is not installed.\n\ Installed versions: {}\n\ - Run 'numan setup nu --version {}' to install, or 'numan use list' to see available versions.", + Run 'numan setup nu {}' to install, or 'numan use list' to see available versions.", version, installed.join(", "), version diff --git a/src/nu/version_manager.rs b/src/nu/version_manager.rs index 029d9a4c..d2b81b10 100644 --- a/src/nu/version_manager.rs +++ b/src/nu/version_manager.rs @@ -32,13 +32,16 @@ pub fn read_active_version(root: &Path) -> Result> { } let content = std::fs::read_to_string(&path) .with_context(|| format!("Failed to read active version from '{}'", path.display()))?; - let active: ActiveVersion = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse active version from '{}'", path.display()))?; + let active: ActiveVersion = match serde_json::from_str(&content) { + Ok(active) => active, + Err(_) => return Ok(None), + }; Ok(Some(active)) } /// Write the active Nu version to the marker file. pub fn write_active_version(root: &Path, version: &str) -> Result<()> { + let version = normalize_version(version)?; let path = active_version_path(root); // Ensure parent directory exists. if let Some(parent) = path.parent() { @@ -59,6 +62,17 @@ pub fn versioned_nu_dir(root: &Path) -> PathBuf { root.join("tools").join("nushell") } +/// Parse and normalize a Nu version, rejecting path-like values. +pub fn normalize_version(version: &str) -> Result { + let version = version.strip_prefix('v').unwrap_or(version); + if version.is_empty() || version.contains('/') || version.contains('\\\\') || version.contains("..") { + bail!("Invalid Nu version '{}'; expected X.Y.Z", version) + } + let parsed = semver::Version::parse(version) + .with_context(|| format!("Invalid Nu version '{}'; expected X.Y.Z", version))?; + Ok(parsed.to_string()) +} + /// Directory for a specific Nu version install. pub fn version_install_dir(root: &Path, version: &str) -> PathBuf { versioned_nu_dir(root).join(version) @@ -77,7 +91,8 @@ pub fn active_nu_binary(root: &Path) -> Result> { let Some(active) = read_active_version(root)? else { return Ok(None); }; - let binary = version_binary(root, &active.version); + let version = normalize_version(&active.version)?; + let binary = version_binary(root, &version); if binary.exists() { Ok(Some(binary)) } else { @@ -120,7 +135,9 @@ pub fn list_installed_versions(root: &Path) -> Result> { /// Check if a specific Nu version is installed. pub fn is_version_installed(root: &Path, version: &str) -> bool { - version_binary(root, version).exists() + normalize_version(version) + .map(|version| version_binary(root, &version).exists()) + .unwrap_or(false) } /// Get the latest installed Nu version, or `None` if no versions are installed. @@ -183,6 +200,7 @@ pub fn migrate_legacy_install(root: &Path) -> Result { let version = parse_nu_version_from_output(&version_output)?; // Move the legacy binary to the versioned directory. + let version = normalize_version(&version)?; let version_dir = version_install_dir(root, &version); std::fs::create_dir_all(&version_dir).with_context(|| { format!( @@ -204,7 +222,9 @@ pub fn migrate_legacy_install(root: &Path) -> Result { write_active_version(root, &version)?; // Try to remove the now-empty legacy directory (ignore errors if not empty). - let _ = std::fs::remove_dir(legacy_binary.parent().unwrap()); + if let Some(parent) = legacy_binary.parent() { + let _ = std::fs::remove_dir(parent); + } Ok(true) } From b8f4233c5f048d92456bfc073bbf3f1a952977a5 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sat, 1 Aug 2026 09:38:57 -0700 Subject: [PATCH 09/61] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20validate=20before=20delete,=20fix=20help=20text,=20fix=20com?= =?UTF-8?q?pilation=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .tmp_parse.py | 10 ++++++++++ .tmp_threads.json | 1 + src/cli.rs | 2 +- src/cmd/setup.rs | 10 ++++++++++ src/cmd/use_cmd.rs | 5 ++--- src/nu/version_manager.rs | 6 +++++- 6 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 .tmp_parse.py create mode 100644 .tmp_threads.json diff --git a/.tmp_parse.py b/.tmp_parse.py new file mode 100644 index 00000000..ca9a5bd3 --- /dev/null +++ b/.tmp_parse.py @@ -0,0 +1,10 @@ +import json, sys +sys.stdout.reconfigure(encoding='utf-8') +data = json.load(open('.tmp_threads.json', encoding='utf-8')) +threads = data['data']['repository']['pullRequest']['reviewThreads']['nodes'] +unresolved = [t for t in threads if not t['isResolved']] +print(f"Total unresolved: {len(unresolved)}\n") +for t in unresolved: + c = t['comments']['nodes'][0] + body_short = c['body'][:120].replace('\n', ' ') + print(f"{t['id']} outdated={t['isOutdated']} {t['path']}:{t.get('line','?')} [{c['author']['login']}] {body_short}") diff --git a/.tmp_threads.json b/.tmp_threads.json new file mode 100644 index 00000000..198615e9 --- /dev/null +++ b/.tmp_threads.json @@ -0,0 +1 @@ +{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"PRRT_kwDOTGvfHs6VpHsH","isResolved":false,"isOutdated":true,"path":"src/cmd/setup.rs","line":300,"originalLine":300,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"This message is printed whenever the managed Nu directory is removed, but it hard-codes ΓÇ£replaced by --use-existingΓÇ¥. The same helper is also used by `setup nu path`, so the message is misleading."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsW","isResolved":false,"isOutdated":true,"path":"src/cmd/use_cmd.rs","line":null,"originalLine":24,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"`migrate_legacy_install()` returns a `Result`, but the error is currently ignored. If the legacy `nu` exists but `--version` fails or rename fails, `numan use` will silently proceed and may later fail in less obvious ways. ItΓÇÖs better to propagate the migration error (or at least surface it) before continuing."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsZ","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":44,"originalLine":41,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"User-facing guidance still references the old `setup nu --version ` flag, but `setup nu` is now positional (`setup nu `).\n\nThis issue also appears in the following locations of the same file:\n- line 64\n- line 77"}]}},{"id":"PRRT_kwDOTGvfHs6VpHsj","isResolved":false,"isOutdated":true,"path":"src/nu/version_manager.rs","line":39,"originalLine":37,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"The docstring says this returns `None` when the marker file is invalid, but a JSON parse error currently returns `Err(...)`. Either the docstring should be tightened, or the implementation should treat parse failures as ΓÇ£no active versionΓÇ¥ (so callers can fall back safely)."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsp","isResolved":false,"isOutdated":true,"path":"src/cli.rs","line":65,"originalLine":65,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"The `Use` command is now implemented in this PR, but the CLI help text still says itΓÇÖs ΓÇ£reserved, post-1.0ΓÇ¥, which is misleading."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsu","isResolved":false,"isOutdated":true,"path":"AGENTS.md","line":68,"originalLine":68,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"AGENTS.md describes `use_cmd.rs` as a ΓÇ£reserved stubΓÇ¥, but this PR adds a working `numan use` implementation. Updating this keeps the project structure doc accurate."}]}},{"id":"PRRT_kwDOTGvfHs6VpHs5","isResolved":false,"isOutdated":true,"path":"src/nu/bootstrap.rs","line":686,"originalLine":686,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"In `execute_nu_setup_with_installer`, a second `numan setup nu` run in non-TTY (scripts/CI) will now auto-confirm the reinstall prompt and proceed to re-download/reinstall without an explicit `--force`, which is not idempotent and differs from the prior non-TTY behavior. Also, with versioned installs `prepend_process_path(managed_nu_dir(root))` no longer prepends a directory that actually contains `nu` (the binary lives under `/tools/nushell//`). Consider treating non-TTY like the no-op path (ensure PATH/persist if needed, then return) unless `--force` is set, and prepend the binaryΓÇÖs parent directory instead of `/tools/nushell`."}]}},{"id":"PRRT_kwDOTGvfHs6VpHzm","isResolved":true,"isOutdated":true,"path":"src/nu/version_manager.rs","line":null,"originalLine":207,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n1\\. legacy_binary.parent().unwrap() may panic ≡ƒôÿ Rule violation Γÿ╝ Reliability\n\n

\nmigrate_legacy_install() uses unwrap() on legacy_binary.parent(), which can panic and crash\ncallers if the path ever lacks a parent. Library code must return errors instead of panicking.\n
\n\n\n
\nAgent Prompt\n\n```\n## Issue description\n`src/nu/version_manager.rs` uses `legacy_binary.parent().unwrap()` in non-test code, which can panic.\n\n## Issue Context\nCompliance requires library code to avoid panic triggers like `unwrap()`/`expect()` and return errors instead.\n\n## Fix Focus Areas\n- src/nu/version_manager.rs[206-208]\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHzr","isResolved":false,"isOutdated":true,"path":"src/cmd/setup.rs","line":239,"originalLine":239,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n3\\. setup nu path persists path 📘 Rule violation ≡ Correctness\n\n
\nThe new setup nu path/setup nu use flows call register_existing_nu(), which persists PATH\nchanges (e.g., PowerShell user PATH update on Windows), writing outside $NUMAN_ROOT. Install-time\ncode must not modify locations outside $NUMAN_ROOT.\n
\n\n\n
\nAgent Prompt\n\n```\n## Issue description\n`setup nu path`/`setup nu use` invoke `register_existing_nu()`, which persists PATH updates (outside `$NUMAN_ROOT`).\n\n## Issue Context\nCompliance requires install-time code paths to avoid writes/modifications outside `$NUMAN_ROOT` (e.g., user PATH, home dir, system directories).\n\n## Fix Focus Areas\n- src/cmd/setup.rs[231-251]\n- src/nu/bootstrap.rs[441-472]\n- src/nu/bootstrap.rs[496-512]\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHzw","isResolved":true,"isOutdated":false,"path":"src/nu/version_manager.rs","line":85,"originalLine":71,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Action\n\n4\\. Version path traversal risk 🐞 Bug ⛨ Security\n\n
\nVersion strings are used directly as path components for install directories and active-binary\nresolution, so values like "../…" or absolute paths can escape $NUMAN_ROOT/tools/nushell/ and\nlater cause Numan to reference or execute an unintended binary. This breaks the managed-root\nboundary and can overwrite files outside the root during installation.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\nNu version identifiers are currently used directly as filesystem path components (e.g., `/tools/nushell//nu`). Without validation, a crafted version containing path separators, `..`, or an absolute path can escape the intended directory and later be treated as the active Nu binary.\n\n### Issue Context\n- `version_install_dir()` joins `version` into the managed directory.\n- `install_from_archive()` uses `version_install_dir()` / `version_binary()` to create directories and copy binaries.\n- `numan use ` can write arbitrary strings into the active marker, and downstream code resolves that into a binary path.\n\n### Fix Focus Areas\n- src/nu/version_manager.rs[57-124]\n- src/nu/bootstrap.rs[197-241]\n- src/cmd/use_cmd.rs[72-100]\n\n### Implementation guidance\n- Introduce a strict parser/validator for Nu versions (recommended: accept `vX.Y.Z` and normalize to `X.Y.Z`; otherwise error).\n- Reject any version containing path separators (`/` or `\\\\`), `..`, or absolute paths.\n- After building the install path, defensively ensure it is still under `versioned_nu_dir(root)` (e.g., via canonicalization when possible).\n- Ensure `write_active_version()` only persists normalized/validated versions, and `numan use` rejects invalid versions early.\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHzz","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":28,"originalLine":25,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n5\\. Migration errors are dropped 🐞 Bug ☼ Reliability\n\n
\nnuman use runs legacy-install migration but discards any error, so failures (e.g., legacy `nu\n--version` execution or parse failures) are silently ignored and users can get misleading ΓÇ£No Nu\nversions installedΓÇ¥ outcomes. This loses critical diagnostics during upgrade from the legacy layout.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\n`use_cmd::execute()` ignores the `Result` from `migrate_legacy_install()`, hiding real failures in migration and causing confusing follow-on behavior.\n\n### Issue Context\n`migrate_legacy_install()` can fail when:\n- executing the legacy binary,\n- parsing its version output,\n- creating the target directory,\n- renaming the binary.\n\n### Fix Focus Areas\n- src/cmd/use_cmd.rs[21-30]\n- src/nu/version_manager.rs[132-210]\n\n### Implementation guidance\n- Replace `let _ = migrate_legacy_install(root);` with `migrate_legacy_install(root).with_context(...)?;`.\n- If you truly want best-effort migration, log a warning to stderr on error and continue, but do not silently discard it.\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHz1","isResolved":true,"isOutdated":false,"path":"src/nu/version_manager.rs","line":39,"originalLine":37,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n6\\. Marker invalid handling mismatch 🐞 Bug ☼ Reliability\n\n
\nread_active_version() claims it returns None when the marker is invalid, but it actually returns\nan error on JSON parse failure, which can break numan use list/latest if the marker is corrupted.\nThis is a contract mismatch between documentation and behavior.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\n`read_active_version()` documents ΓÇ£Returns None if marker is invalidΓÇ¥ but propagates JSON parse errors instead.\n\n### Issue Context\nThis file is user-local state (`/nu_state/active-version.json`) and can be corrupted; list/switch commands should either degrade gracefully or document that corruption is fatal.\n\n### Fix Focus Areas\n- src/nu/version_manager.rs[25-38]\n\n### Implementation guidance\nChoose one:\n- If invalid should be non-fatal (matches current docs): catch `serde_json::from_str` errors and return `Ok(None)` (optionally `eprintln!` a warning).\n- If invalid should be fatal: update the doc comment and ensure callers surface a clear remediation message (e.g., delete the marker / rerun setup).\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHz2","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":44,"originalLine":41,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Informational\"\n\n7\\. Use guidance uses deprecated flag 🐞 Bug ≡ Correctness\n\n
\nnuman use still tells users to run numan setup nu --version ..., but this PR redesigns setup to\naccept a positional version (numan setup nu <VERSION>). These messages appear on the common\nempty-install and version-not-installed paths, so users will be instructed to run a non-existent\nflag.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\n`use_cmd` emits outdated setup guidance using `--version`, which no longer matches the new `setup nu` CLI.\n\n### Issue Context\nThe PR changes setup syntax (see `hints::setup_nu_version()`), but `use_cmd` hard-codes old strings.\n\n### Fix Focus Areas\n- src/cmd/use_cmd.rs[38-41]\n- src/cmd/use_cmd.rs[64-68]\n- src/cmd/use_cmd.rs[77-93]\n- src/util/hints.rs[21-27]\n\n### Implementation guidance\n- Replace hard-coded `setup nu --version ...` strings with `hints::setup_nu_version(version)` (or update to `numan setup nu `).\n- Ensure the empty-install and not-installed branches print the correct new syntax.\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpH0_","isResolved":false,"isOutdated":true,"path":"src/cmd/setup.rs","line":243,"originalLine":243,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Validate the replacement before deleting managed Nu**\n\nWhen managed versions exist, `numan setup nu use ` deletes the entire managed directory here before `register_existing_nu` canonicalizes or validates the replacement. An interactive user who declines the later PATH prompt also loses every managed version even though the command reports cancellation. Fully validate, confirm, and register the replacement before removing the managed installation.\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH1A","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":102,"originalLine":98,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Refresh cached Nu identity when switching versions**\n\nOn an initialized root, writing only `active-version.json` leaves `nu_state/paths.json` pointing to the old executable and version. Commands such as activate/deactivate load that cached `NuPaths`, while search/install compatibility also prefers its cached version, so `numan use ` prints success but Numan continues resolving packages and invoking the previous Nu until the user separately runs `numan init --refresh`. The switch must update/re-probe the cached identity or perform the refresh as part of the command.\n\nAGENTS.md reference: [AGENTS.md:L141-L141](https://github.com/tonythethompson/numan/blob/8062a11ebc4bd26546e99b000e5f9cb0981235e7/AGENTS.md#L141-L141)\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH1B","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":227,"originalLine":227,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Repoint the managed symlink for a new version**\n\nOn Unix after a normal first setup, `~/.local/bin/nu` points to that version-specific binary. Installing another version now creates a different target, but the subsequent `persist_user_path_unix` call rejects an existing symlink whose target differs, so `setup nu --force`—including the managed-pin offer—downloads the release and marks it active, then exits with an error. A symlink that points inside Numan's managed directory needs to be atomically repointed when the active version changes.\n\nAGENTS.md reference: [AGENTS.md:L232-L232](https://github.com/tonythethompson/numan/blob/8062a11ebc4bd26546e99b000e5f9cb0981235e7/AGENTS.md#L232-L232)\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH1D","isResolved":false,"isOutdated":true,"path":"src/cmd/use_cmd.rs","line":null,"originalLine":24,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) Serialize numan use mutations**\n\nThis entry point can move the legacy binary, create version directories, and rewrite `active-version.json`, but it never acquires the root mutation lock. If `numan use` or even `numan use list` runs concurrently with `setup nu`, the migration and active-marker writes race the locked installer, allowing either command to silently overwrite the other's selected version. Acquire `acquire_mutation_lock(root)` around migration and switching while keeping a truly read-only list path separate.\n\nAGENTS.md reference: [AGENTS.md:L139-L139](https://github.com/tonythethompson/numan/blob/8062a11ebc4bd26546e99b000e5f9cb0981235e7/AGENTS.md#L139-L139)\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH6k","isResolved":true,"isOutdated":true,"path":"src/cmd/use_cmd.rs","line":null,"originalLine":40,"comments":{"nodes":[{"author":{"login":"greptile-apps"},"body":"\"P1\" **Stale `--version` flag in help text**\n\nThree locations in this new file still reference `--version`, the deprecated flag this PR removes. `execute_list` (line 40), `execute_latest` (line 66), and `execute_switch` (lines 80–81, 87) all tell users to run `numan setup nu --version ` — but the correct new invocation is `numan setup nu `. Users copying these error hints will get a deprecation warning on top of the problem they're already debugging.\n\n
Prompt To Fix With AI\n\n`````markdown\nThis is a comment left during a code review.\nPath: src/cmd/use_cmd.rs\nLine: 40\n\nComment:\n**Stale `--version` flag in help text**\n\nThree locations in this new file still reference `--version`, the deprecated flag this PR removes. `execute_list` (line 40), `execute_latest` (line 66), and `execute_switch` (lines 80ΓÇô81, 87) all tell users to run `numan setup nu --version ` ΓÇö but the correct new invocation is `numan setup nu `. Users copying these error hints will get a deprecation warning on top of the problem they're already debugging.\n\n---\n\nFor each issue above, determine whether it is valid and should be fixed. If so, fix it directly.\n`````\n
\n\n\"Fix"}]}},{"id":"PRRT_kwDOTGvfHs6VpH6n","isResolved":true,"isOutdated":true,"path":"src/cmd/setup.rs","line":300,"originalLine":300,"comments":{"nodes":[{"author":{"login":"greptile-apps"},"body":"\"P2\" The printed message still references `--use-existing`, the deprecated flag removed in this PR. It should describe the new subcommand that invoked the deletion.\n\n```suggestion\n println!(\n \"Removed managed Nushell at '{}' (replaced by external binary).\",\n managed_dir.display()\n );\n```\n\n
Prompt To Fix With AI\n\n`````markdown\nThis is a comment left during a code review.\nPath: src/cmd/setup.rs\nLine: 297-300\n\nComment:\nThe printed message still references `--use-existing`, the deprecated flag removed in this PR. It should describe the new subcommand that invoked the deletion.\n\n```suggestion\n println!(\n \"Removed managed Nushell at '{}' (replaced by external binary).\",\n managed_dir.display()\n );\n```\n\n---\n\nFor each issue above, determine whether it is valid and should be fixed. If so, fix it directly.\n`````\n
\n\nNote: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!\n\n\"Fix"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrb","isResolved":false,"isOutdated":false,"path":"CLAUDE.md","line":36,"originalLine":36,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nif command -v serena >/dev/null 2>&1; then\n serena tools list\nelif command -v uv >/dev/null 2>&1; then\n uv run serena tools list\nelse\n echo \"Serena is not installed; verify the pinned tool names manually.\" >&2\n exit 1\nfi\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 223\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- CLAUDE.md lines 1-50 ---'\nsed -n '1,50p' CLAUDE.md\n\nprintf '%s\\n' '--- Serena-related tracked files and references ---'\ngit ls-files | rg -i '(^|/)(agents\\.md|claude\\.md|serena|pyproject\\.toml|uv\\.lock|requirements.*|package\\.json|mcp|config)' || true\nrg -n -i 'serena|find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol|jet_brains_move|_find_implementations|_insert_after_symbol|_safe_delete|rename\\b' . \\\n -g '!node_modules' -g '!dist' -g '!build' || true\n\nprintf '%s\\n' '--- relevant dependency/config excerpts ---'\nfor f in pyproject.toml uv.lock requirements.txt requirements-dev.txt package.json .mcp.json; do\n if [ -f \"$f\" ]; then\n echo \"### $f\"\n rg -n -i -C 3 'serena|oraios|mcp' \"$f\" || true\n fi\ndone\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 5756\n\n---\n\n🌐 Web query:\n\n`Find the authoritative Serena documentation for the current tool names and the versioned tool list, specifically `find_implementations`, `insert_after_symbol`, `rename_symbol`, `safe_delete_symbol`, and `jet_brains_move`.`\n\n💡 Result:\n\nThe authoritative documentation for Serena tools is maintained by Oraios AI and can be accessed via their official documentation portal [1][2]. Serena is a toolkit that provides semantic code tools for coding agents using the Model Context Protocol (MCP). It operates using either a Language Server Protocol (LSP) backend or, when configured, a JetBrains-specific backend [3][4]. Tool Definitions and Status: - find_implementations: Finds symbols that implement a given symbol using the language server backend [5][1]. - insert_after_symbol: Inserts content after the end of the definition of a given symbol [5][6]. - rename_symbol: Renames a symbol throughout the codebase, leveraging language server refactoring capabilities [5][7]. - safe_delete_symbol: Deletes a symbol, reporting the reference count if it is blocked [8]. - jet_brains_move: Documentation indicates that Serena uses specific JetBrains-prefixed tools for JetBrains-backed operations (e.g., jet_brains_find_implementations) [5][9]. There is no official \"jet_brains_move\" tool listed in the standard Serena documentation; refactoring operations like moving symbols are typically handled through the IDE backend's native capabilities when integrated [3][4]. You can view the complete, up-to-date tool list by running the command ` tools list --all` [10]. For the most recent technical references, refer to the official Serena Tools documentation page [1][11].\n\nCitations:\n\n- 1: https://oraios.github.io/serena/01-about/035_tools.html\n- 2: https://oraios.github.io/serena/01-about/000_intro.html\n- 3: https://graphify.net/mcpservers/serena/\n- 4: https://deepwiki.com/oraios/serena/7.3-tool-reference\n- 5: https://oraios.github.io/serena/_sources/01-about/035_tools.md\n- 6: https://github.com/oraios/serena/blob/0c915bd1/src/serena/tools/symbol_tools.py\n- 7: https://mcp.directory/blog/serena-mcp-complete-guide-2026\n- 8: https://github.com/postfix/serena\n- 9: https://github.com/oraios/serena/blob/981f560f/src/serena/tools/jetbrains_tools.py\n- 10: https://github.com/oraios/serena/blob/9cd33aa5/docs/01-about/035_tools.md\n- 11: https://github.com/oraios/serena/blob/main/README.md\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -euo pipefail\n\nbase='https://raw.githubusercontent.com/oraios/serena/main'\n\nprintf '%s\\n' '--- official tool documentation: asserted names ---'\ncurl -fsSL \"$base/docs/01-about/035_tools.md\" |\n rg -n -C 2 'find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol|jet_brains_move|move' || true\n\nprintf '%s\\n' '--- official source: symbol tools ---'\ncurl -fsSL \"$base/src/serena/tools/symbol_tools.py\" |\n rg -n 'def (find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol)|find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol' || true\n\nprintf '%s\\n' '--- official source: JetBrains tools ---'\ncurl -fsSL \"$base/src/serena/tools/jetbrains_tools.py\" |\n rg -n -C 2 'def .*move|jet_brains_move|move' || true\n\nprintf '%s\\n' '--- official repository refs for available tags ---'\ncurl -fsSL 'https://api.github.com/repos/oraios/serena/tags?per_page=10' |\n jq -r '.[] | [.name, .commit.sha] | `@tsv`' || true\n```\n\nLength of output: 2113\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- all tracked dependency/config candidates ---'\ngit ls-files | rg -i '(^|/)(Cargo\\.toml|Cargo\\.lock|pyproject\\.toml|uv\\.lock|requirements[^/]*|package(-lock)?\\.json|.*mcp.*|.*serena.*|.*config.*)$' || true\n\nprintf '%s\\n' '--- later CLAUDE.md workflow references ---'\nsed -n '50,75p' CLAUDE.md\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 1507\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -euo pipefail\n\nfor ref in main 0c915bd1 981f560f 9cd33aa5; do\n echo \"--- symbol_tools.py ref=$ref ---\"\n curl -fsSL \"https://raw.githubusercontent.com/oraios/serena/$ref/src/serena/tools/symbol_tools.py\" |\n sed -n '560,730p' |\n rg -n -C 5 'def |tool|find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol' || true\n\n echo \"--- jetbrains_tools.py ref=$ref ---\"\n curl -fsSL \"https://raw.githubusercontent.com/oraios/serena/$ref/src/serena/tools/jetbrains_tools.py\" |\n sed -n '100,245p' |\n rg -n -C 8 'def |tool|move|delete' || true\ndone\n```\n\nLength of output: 27259\n\n---\n\n🌐 Web query:\n\n`site:github.com/oraios/serena \"find_implementations\" \"safe_delete_symbol\" \"jet_brains_move\"`\n\n💡 Result:\n\nThe `safe_delete_symbol` and `jet_brains_move` tools are implementations found within the Serena project, which interfaces with a JetBrains IDE plugin to perform refactoring tasks [1][2][3]. The `safe_delete_symbol` tool is a wrapper that utilizes the `safeDelete` endpoint provided by the JetBrains plugin client [1][2]. It is designed to safely delete a symbol, file, or directory after checking for existing usages [1]. It supports parameters such as `name_path`, `relative_path`, `delete_even_if_used`, and `propagate` to control the deletion process [1][2]. The `jet_brains_move` tool is implemented to handle moving symbols to different locations (such as a new file or parent symbol) using the IDE's internal refactoring engine [3]. Similar to the delete functionality, it is exposed through the JetBrains plugin client, requiring source identifiers (like `name_path` or `relative_path`) and target identifiers (like `target_parent_name_path` or `target_relative_path`) to perform the operation [3]. These tools are configured and enabled within the project's internal mode configuration files (e.g., `jetbrains.yml`) and are utilized by the Serena agent to automate complex code refactoring tasks safely, reducing the need for manual, error-prone operations [4][3][5].\n\nCitations:\n\n- 1: https://github.com/oraios/serena/blob/a0984015/src/serena/tools/jetbrains_tools.py\n- 2: https://github.com/oraios/serena/blob/a0984015/src/serena/jetbrains/jetbrains_plugin_client.py\n- 3: https://github.com/oraios/serena/pull/1223\n- 4: https://github.com/oraios/serena/blob/901bd215/src/serena/resources/config/internal_modes/jetbrains.yml\n- 5: https://github.com/oraios/serena/issues/792\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -euo pipefail\n\nfor ref in main a0984015 0c915bd1; do\n echo \"--- $ref: implementation lookup and tool registration ---\"\n for file in src/serena/tools/symbol_tools.py src/serena/tools/__init__.py src/serena/agent.py src/serena/tools/tool.py; do\n url=\"https://raw.githubusercontent.com/oraios/serena/$ref/$file\"\n body=\"$(curl -fsSL \"$url\" 2>/dev/null || true)\"\n if [ -n \"$body\" ]; then\n echo \"### $file\"\n printf '%s\\n' \"$body\" |\n rg -n -C 4 'FindImplementations|find_implementations|SafeDeleteSymbol|RenameSymbolTool|InsertAfterSymbolTool|class .*Tool|snake|tool_name|name =' || true\n fi\n done\ndone\n```\n\nLength of output: 50343\n\n---\n\n\n\n
\n\n**Use valid, version-pinned Serena tool names.**\n\nReplace the invalid names with `find_implementations`, `insert_after_symbol`, `rename_symbol`, and `safe_delete_symbol`. List `jet_brains_move` only as an optional JetBrains-backend tool. Add a Serena version pin because this repository has none.\n\n
\n🧰 Tools\n\n
\n🪛 markdownlint-cli2 (0.23.1)\n\n[warning] 34-34: Spaces inside emphasis markers\n\n(MD037, no-space-in-emphasis)\n\n
\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@CLAUDE.md` around lines 22 - 36, Update the tool mapping in CLAUDE.md to\nreplace invalid Serena names with find_implementations, insert_after_symbol,\nrename_symbol, and safe_delete_symbol. List jet_brains_move only as an optional\nJetBrains-backend tool, and add the required Serena version pin using the\nrepository’s documented versioning convention.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrf","isResolved":false,"isOutdated":false,"path":"CLAUDE.md","line":124,"originalLine":124,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n**Do not force a model-specific co-author trailer.**\n\nThese lines require every commit created under these instructions to carry a fixed identity and model version. That metadata can become stale or create unauthorized provenance. Add attribution only when the user explicitly requests the exact trailer.\n\n
\nProposed correction\n\n```diff\n-For commit messages, use a HEREDOC to preserve formatting. End the trailer with:\n-Co-Authored-By: Claude Opus 4.7 (1M context) \n+For commit messages, use a HEREDOC to preserve formatting. Add co-author attribution only when the user explicitly requests it.\n```\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@CLAUDE.md` around lines 123 - 124, Update the commit-message guidance in\nCLAUDE.md to remove the mandatory model-specific Co-Authored-By trailer and\nrequire that exact attribution only when the user explicitly requests it; retain\nthe HEREDOC formatting guidance.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrh","isResolved":false,"isOutdated":false,"path":"docs/numan-doctor.md","line":81,"originalLine":81,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Validate the replacement before deleting managed Nu.**\n\n`doctor --fix` maps `nu.binary.found_off_path` to `numan setup nu use ` at all three references. The current setup flow removes the managed Nu directory before it validates and registers ``. A stale or invalid path can therefore destroy a working managed installation.\n\nValidate the candidate and protect state before replacement. Remove old files only after registration succeeds. Fix this in `src/cmd/setup.rs`; changing the hint text is not sufficient.\n\nAs per coding guidelines, mutating paths must protect state before writes.\n\n\n\n\n\n\n\n\nAlso applies to: 118-118, 266-266\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@docs/numan-doctor.md` around lines 80 - 81, Update the setup flow in\nsrc/cmd/setup.rs for the nu.binary.found_off_path path so the candidate \nis validated and successfully registered before removing the managed Nu\ndirectory. Preserve the existing managed installation until registration\ncompletes, and only delete or replace old files after that operation succeeds;\napply this consistently to every setup-nu replacement path.\n```\n\n
\n\n\n\n\n\n\n\n_Sources: Coding guidelines, MCP tools_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJri","isResolved":false,"isOutdated":false,"path":"docs/plans/consolidated-multi-repo-roadmap.md","line":328,"originalLine":328,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n**\"Stub\" description contradicts the implementation shipped in this PR.**\n\nThis line says `numan use` is a stub that prints a \"planned post-1.0\" message. In this same PR, `src/cmd/use_cmd.rs` implements real version switching, latest-selection, and listing, and persists `active-version.json`. Update this line so the roadmap doesn't claim the command is not ready when it is.\n\nThis shares a root cause with the stale doc comment on `Commands::Use` in `src/cli.rs`; see the consolidated comment.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@docs/plans/consolidated-multi-repo-roadmap.md` around lines 327 - 328, Update\nthe `**Command reserved:**` roadmap entry to describe `numan use` as implemented\nand available, including version switching, latest-version selection, listing,\nand active-version persistence, rather than calling it a stub or claiming it is\nplanned post-1.0.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrj","isResolved":true,"isOutdated":false,"path":"README.md","line":193,"originalLine":193,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nrg -n -C 8 'active-version|acquire_mutation_lock|create_snapshot|NuPaths|init.*refresh|activate' \\\n src/cmd/use_cmd.rs src/nu src/state tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50380\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- command and state files ---'\nfd -t f . src | rg '(^|/)(use|activate|init|lock|snapshot|version|paths).*\\.rs$|command|cli'\nprintf '%s\\n' '--- use references ---'\nrg -n -C 6 '\\buse\\b|active-version|ActiveVersion|NuPaths::load|validate_drift|acquire_mutation_lock|create_snapshot' src tests README.md \\\n | head -n 1200\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50379\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path\n\nfor p in sorted(Path(\"src\").rglob(\"*.rs\")):\n text = p.read_text(errors=\"replace\")\n if \"active-version\" in text or \"ActiveVersion\" in text or \"acquire_mutation_lock\" in text or \"create_snapshot\" in text:\n print(f\"\\n--- {p} ---\")\n lines = text.splitlines()\n for i, line in enumerate(lines, 1):\n if any(x in line for x in (\n \"active-version\", \"ActiveVersion\", \"acquire_mutation_lock\",\n \"create_snapshot\", \"NuPaths::load\", \"validate_drift\",\n \"SnapshotTrigger\",\n )):\n lo, hi = max(1, i - 12), min(len(lines), i + 18)\n print(f\"[{lo}-{hi}]\")\n for n in range(lo, hi + 1):\n print(f\"{n}: {lines[n-1]}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- src/cmd/use_cmd.rs ---'\ncat -n src/cmd/use_cmd.rs\n\nprintf '%s\\n' '--- CLI command wiring ---'\ncat -n src/cli.rs\nrg -n -C 12 'Use|use_cmd|SetupNu|setup nu|active_version|ActiveVersion|set_active|activate' src/cmd src/cli.rs\n\nprintf '%s\\n' '--- version manager ---'\ncat -n src/nu/version_manager.rs | sed -n '1,260p'\n\nprintf '%s\\n' '--- init refresh write path ---'\ncat -n src/cmd/init.rs | sed -n '159,240p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path\n\nneedles = (\n \"active_version\", \"ActiveVersion\", \"active-version\",\n \"NuPaths::save\", \".save(root)\", \"acquire_mutation_lock\",\n \"create_snapshot\", \"SnapshotTrigger\",\n)\n\nfor p in [Path(\"src/cmd/use_cmd.rs\"), Path(\"src/cmd/setup.rs\"),\n Path(\"src/cmd/init.rs\"), Path(\"src/nu/version_manager.rs\"),\n Path(\"src/cli.rs\")]:\n if not p.exists():\n continue\n lines = p.read_text(errors=\"replace\").splitlines()\n print(f\"\\n--- {p} ({len(lines)} lines) ---\")\n hits = [i for i, line in enumerate(lines) if any(n in line for n in needles)]\n shown = set()\n for i in hits:\n lo, hi = max(0, i - 15), min(len(lines), i + 16)\n if any(n in shown for n in range(lo, hi)):\n continue\n shown.update(range(lo, hi))\n for n in range(lo, hi):\n print(f\"{n+1}: {lines[n]}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 19687\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- use command ---'\nsed -n '1,105p' src/cmd/use_cmd.rs\n\nprintf '%s\\n' '--- CLI wiring ---'\ncat src/cli.rs\n\nprintf '%s\\n' '--- setup command variants and path selection ---'\nsed -n '23,140p' src/cmd/setup.rs\nsed -n '148,225p' src/cmd/setup.rs\nrg -n -C 8 'active_nu_binary|find_nu_executable_with_root|version_binary|version_install_dir|write_active_version|NuPaths::detect|NuPaths::load' src\n\nprintf '%s\\n' '--- exact version-manager path functions ---'\nsed -n '35,115p' src/nu/version_manager.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path\nimport re\n\nfiles = [\n Path(\"src/cmd/use_cmd.rs\"),\n Path(\"src/nu/version_manager.rs\"),\n Path(\"src/nu/paths.rs\"),\n Path(\"src/cli.rs\"),\n]\n\nfor path in files:\n text = path.read_text()\n print(f\"\\n--- {path} ---\")\n for name in (\n \"execute\", \"execute_switch\", \"write_active_version\",\n \"version_install_dir\", \"version_binary\",\n \"find_nu_executable_with_root\",\n ):\n matches = list(re.finditer(rf\"(?m)^(?:pub\\s+)?(?:fn|async fn)\\s+{name}\\b\", text))\n for m in matches:\n start = text.rfind(\"\\n\", 0, m.start()) + 1\n # Print the function through the next top-level item or 140 lines.\n lines = text[start:].splitlines()\n depth = 0\n out = []\n for line in lines[:160]:\n out.append(line)\n depth += line.count(\"{\") - line.count(\"}\")\n if out and depth <= 0 and \"{\" in \"\\n\".join(out):\n break\n print(\"\\n\".join(out))\nPY\n\nprintf '%s\\n' '--- mutation/snapshot calls in use path ---'\nrg -n 'acquire_mutation_lock|create_snapshot|SnapshotTrigger|NuPaths|lockfile|activation' \\\n src/cmd/use_cmd.rs src/nu/version_manager.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 3446\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path, PurePosixPath\n\n# Read-only behavioral probe for the path construction used by the source:\n# versioned_nu_dir(root).join(version), with no validation step visible in the\n# implementation. This does not execute repository code.\nroot = PurePosixPath(\"/numan\")\nbase = root / \"tools\" / \"nushell\"\nfor version in [\"0.113.1\", \"../outside\", \"../../outside\", \"/absolute\", \"0.113.1/../../outside\"]:\n print(f\"{version!r} -> {(base / version).as_posix()}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 398\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- refresh state reconciliation ---'\nsed -n '186,305p' src/cmd/init.rs\n\nprintf '%s\\n' '--- active-version snapshot coverage ---'\nsed -n '32,205p' src/state/snapshot.rs\nrg -n 'active.version|active-version|active_version|nu_version|nu_executable' \\\n src/state/snapshot.rs src/state/rollback.rs src/cmd/init.rs\n\nprintf '%s\\n' '--- source-level mutation-boundary verifier ---'\npython3 - <<'PY'\nfrom pathlib import Path\n\nuse = Path(\"src/cmd/use_cmd.rs\").read_text()\nactivate = Path(\"src/cmd/activate.rs\").read_text()\nrefresh = Path(\"src/cmd/init.rs\").read_text()\n\ndef body(text, name):\n marker = f\"fn {name}\"\n start = text.index(marker)\n brace = text.index(\"{\", start)\n depth = 0\n for end in range(brace, len(text)):\n if text[end] == \"{\":\n depth += 1\n elif text[end] == \"}\":\n depth -= 1\n if depth == 0:\n return text[start:end + 1]\n raise AssertionError(name)\n\nuse_execute = body(use, \"pub fn execute\")\nuse_switch = body(use, \"fn execute_switch\")\nactivate_execute = body(activate, \"fn execute_with_registrar_and_runner\")\nrefresh_execute = body(refresh, \"fn execute_refresh\")\n\nprint(\"use.execute acquires lock:\", \"acquire_mutation_lock\" in use_execute)\nprint(\"use.execute creates snapshot:\", \"create_snapshot\" in use_execute)\nprint(\"use.execute writes active marker:\", \"write_active_version\" in use_execute)\nprint(\"use.execute refreshes NuPaths:\", \"NuPaths\" in use_execute)\nprint(\"use.execute reconciles activation state:\",\n \"refresh_activation_records\" in use_execute or \"AutoloadState\" in use_execute)\nprint(\"activate loads cached NuPaths before drift validation:\",\n \"NuPaths::load(root)\" in activate_execute and\n activate_execute.index(\"NuPaths::load(root)\") <\n activate_execute.index(\"validate_drift\"))\nprint(\"refresh writes NuPaths:\", \"new_paths.save(root)\" in refresh_execute)\nprint(\"refresh updates activation records:\", \"refresh_activation_records\" in refresh_execute)\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 13450\n\n---\n\n\n\n
\n\n**Make `numan use` a complete, locked state transition.**\n\n`src/cmd/use_cmd.rs` writes `active-version.json` without the mutation lock or a pre-mutation snapshot. It also leaves `NuPaths`, `lockfile.nu_version`, activation records, and autoload state unchanged. `activate` then loads and validates the previous cached Nu executable.\n\nUpdate all dependent state and reconcile registrations before returning. If `numan init --refresh` remains required, block activation until refresh and reactivation complete. Do not treat metadata-only updates as registration reconciliation.\n\nReject version values that escape `/tools/nushell`; raw path joins currently allow values such as `../outside`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@README.md` at line 193, Update the numan use command to perform a complete\nlocked state transition: acquire the mutation lock, snapshot state before\nchanges, and validate the requested version remains under /tools/nushell.\nReconcile NuPaths, lockfile.nu_version, activation records, and autoload\nregistrations before returning instead of only writing active-version.json.\nEnsure activate does not use the previous cached Nu executable; if numan init\n--refresh is required, block activation until refresh and reactivation finish,\nand do not treat metadata-only changes as registration reconciliation.\n```\n\n
\n\n\n\n\n\n\n\n_Sources: Coding guidelines, MCP tools_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrl","isResolved":true,"isOutdated":false,"path":"README.md","line":326,"originalLine":326,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nrg -n -C 6 'tools/nushell|active-version|join\\(|Version::parse|semver' \\\n src/nu src/cmd tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- candidate files ---'\nfd -t f '(^|/)(setup|use|nu|version|tools|paths)' src tests 2>/dev/null | head -200 || true\nprintf '%s\\n' '--- command and path references ---'\nrg -n -S -g '*.rs' \\\n 'enum .*Command|Setup|Use|setup nu|use |Nushell|nushell|tools|active_version|active-version|parse_version|Version::parse|NuVersion' \\\n src tests --glob '!src/cmd/activate.rs' --glob '!src/cmd/doctor.rs' --glob '!tests/nupm_compat_test.rs' \\\n | head -500\nprintf '%s\\n' '--- README command context ---'\nsed -n '300,340p' README.md\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 37848\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- setup.rs ---'\ncat -n src/cmd/setup.rs | sed -n '20,260p'\nprintf '%s\\n' '--- use_cmd.rs ---'\ncat -n src/cmd/use_cmd.rs | sed -n '1,230p'\nprintf '%s\\n' '--- version_manager.rs ---'\ncat -n src/nu/version_manager.rs | sed -n '1,330p'\nprintf '%s\\n' '--- bootstrap.rs relevant ranges ---'\ncat -n src/nu/bootstrap.rs | sed -n '1,260p'\ncat -n src/nu/bootstrap.rs | sed -n '620,720p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 44580\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- install_version and installer paths ---'\ncat -n src/nu/bootstrap.rs | sed -n '270,380p'\nprintf '%s\\n' '--- active binary consumers ---'\nrg -n -C 5 'managed_nu_binary|active_nu_binary|version_binary|version_install_dir|nu_executable' \\\n src --glob '*.rs' \\\n | head -400\nprintf '%s\\n' '--- existing validation and tests ---'\nrg -n -C 4 'version.*(valid|invalid|parse|travers)|travers.*version|install_from_archive|execute_nu_setup|is_version_installed|write_active_version' \\\n src tests --glob '*.rs' \\\n | head -500\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- managed binary call sites ---'\nrg -n -C 4 'managed_nu_binary\\(|active_nu_binary\\(|find_nu_executable_with_root\\(' \\\n src --glob '*.rs'\nprintf '%s\\n' '--- exact release-tag flow ---'\ncat -n src/nu/bootstrap.rs | sed -n '99,120p'\ncat -n src/nu/bootstrap.rs | sed -n '280,320p'\nprintf '%s\\n' '--- standalone path and URL probe ---'\npython3 - <<'PY'\nfrom pathlib import PurePosixPath\nfrom urllib.parse import urljoin\n\nroot = PurePosixPath(\"/tmp/numan-root\")\nbase = root / \"tools\" / \"nushell\"\n\ndef rust_join(base, child):\n # Path::join replaces the base when the child is absolute.\n child_path = PurePosixPath(child)\n return child_path if child_path.is_absolute() else base / child_path\n\nfor value in [\"0.113.1\", \"../../outside\", \"../../../tmp/nu\", \"/tmp/nu\"]:\n binary = rust_join(base, value) / \"nu\"\n print(f\"{value!r} -> {binary}\")\n\nrelease_api = \"https://api.github.com/repos/nushell/nushell/releases/tags/\"\nfor value in [\"0.113.1\", \"../../outside\", \"/tmp/nu\"]:\n print(f\"tag input {value!r} -> {urljoin(release_api, value)}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 10577\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\npython3 - <<'PY'\nimport os\nimport tempfile\n\nwith tempfile.TemporaryDirectory() as tmp:\n root = os.path.join(tmp, \"root\")\n os.makedirs(os.path.join(root, \"tools\", \"nushell\"))\n outside = os.path.join(root, \"outside\", \"nu\")\n os.makedirs(os.path.dirname(outside))\n open(outside, \"wb\").close()\n\n version = \"../../outside\"\n candidate = os.path.join(root, \"tools\", \"nushell\", version, \"nu\")\n print(\"candidate:\", candidate)\n print(\"normalized:\", os.path.realpath(candidate))\n print(\"exists:\", os.path.isfile(candidate))\n print(\"escapes_root:\", not os.path.realpath(candidate).startswith(\n os.path.realpath(root) + os.sep\n ))\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 301\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\npython3 - <<'PY'\nimport os\nimport tempfile\n\nwith tempfile.TemporaryDirectory() as tmp:\n root = os.path.join(tmp, \"root\")\n os.makedirs(os.path.join(root, \"tools\", \"nushell\"))\n outside = os.path.join(tmp, \"outside\", \"nu\")\n os.makedirs(os.path.dirname(outside))\n open(outside, \"wb\").close()\n\n for version in (\"../../outside\", \"../../../outside\"):\n candidate = os.path.join(root, \"tools\", \"nushell\", version, \"nu\")\n resolved = os.path.realpath(candidate)\n print(version, \"->\", resolved, \"exists:\", os.path.isfile(candidate),\n \"escapes_root:\", os.path.commonpath([root, resolved]) != root)\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 323\n\n---\n\n\n\n
\n\n**Validate version arguments before URL and path handling.**\n\n`numan use ` passes raw input to `version_install_dir`; `../../../outside` can make `is_version_installed` select a binary outside `$NUMAN_ROOT`. The active marker then causes `managed_nu_binary` to return that path. Validate version values before path construction. Validate `setup nu [VERSION]` before interpolating the value into the release-tag URL. Accept only normalized semver values, excluding `latest` and `list` where applicable. Add traversal tests.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@README.md` at line 326, Validate version arguments at the command boundary\nbefore any path construction or release-tag URL interpolation, including the\nnuman use flow around version_install_dir and the setup nu [VERSION] flow.\nAccept only normalized semver values, excluding latest and list where\napplicable, and reject traversal or other invalid input; ensure invalid values\ncannot influence is_version_installed or managed_nu_binary paths. Add tests\ncovering traversal attempts and the relevant setup/use validation behavior.\n```\n\n
\n\n\n\n\n\n\n\n_Sources: Coding guidelines, MCP tools_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrm","isResolved":false,"isOutdated":false,"path":"README.md","line":329,"originalLine":329,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Align documentation with the implemented `numan use` contract.**\n\nThe PR implements version selection, `latest`, and `list`, but the documentation set still presents the command as deferred and omits it from the user-facing reference and release notes.\n\n- `README.md#L326-L329`: add `numan use `, `numan use latest`, and `numan use list`.\n- `AGENTS.md#L68-L68`: replace the reserved-stub description with the current behavior.\n- `CHANGELOG.md#L10-L24`: add `numan use` and active-version tracking to the Unreleased notes.\n\n
\n📍 Affects 3 files\n\n- `README.md#L326-L329` (this comment)\n- `AGENTS.md#L68-L68`\n- `CHANGELOG.md#L10-L24`\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@README.md` around lines 326 - 329, Update the documentation for the\nimplemented numan use contract: in README.md lines 326-329, add entries for\nnuman use , numan use latest, and numan use list; in AGENTS.md line 68,\nreplace the reserved-stub description with the current behavior; and in\nCHANGELOG.md lines 10-24, add Unreleased notes covering numan use and\nactive-version tracking.\n```\n\n
\n\n\n\n\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJro","isResolved":false,"isOutdated":false,"path":"src/cli.rs","line":65,"originalLine":65,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n**Doc comment says \"reserved\" but the command is fully implemented.**\n\nThe doc comment reads \"(reserved, post-1.0)\", but `Commands::Use` dispatches to `use_cmd::execute`, which switches versions, lists installs, and persists `active-version.json`. This is not a reserved stub. Update the doc comment to describe the actual behavior. Do not tell users a working command is not ready — that misleads them.\n\nThis shares a root cause with the stub description in `docs/plans/consolidated-multi-repo-roadmap.md`; see the consolidated comment.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cli.rs` around lines 64 - 65, Update the doc comment for the\nCommands::Use variant to describe its implemented behavior—switching the active\nmanaged Nu version—instead of labeling it reserved or post-1.0. Keep the command\nwiring unchanged.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrq","isResolved":false,"isOutdated":false,"path":"src/cmd/activate.rs","line":1695,"originalLine":1695,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Three tests for the new confirmation behaviour skip themselves when a TTY is present.** The shared root cause is that `confirm_or_auto` reads the global `std::io::stdin()` terminal state directly. No test can control that state, so each test wraps its assertions in an `is_terminal` guard and reports success without asserting anything on a developer machine. The behaviour these tests exist to protect is then verified only in CI, and only by accident of the environment. Inject the TTY state into the confirmation helper, then assert both branches unconditionally.\n- `src/cmd/activate.rs#L1686-L1695`: drop the `is_terminal` guard and assert the non-TTY policy against an injected terminal state instead of the process stdin.\n- `src/cmd/deactivate.rs#L1599-L1605`: drop the `is_terminal` guard and assert the same way; this test also has no doc comment explaining the guard, unlike its counterpart in `src/cmd/activate.rs`.\n- `tests/activate_test.rs#L612-L621`: keep the end-to-end activation assertion, but remove the early return so the test either runs or is marked `#[ignore]` rather than passing vacuously.\n\n\n\n\n\nBased on coding guidelines: \"Unit tests must use injectable seams such as `FakeCandidateRunner` and registrars\" and \"Behavior changes require corresponding tests, including relevant failure paths.\"\n\n
\n📍 Affects 3 files\n\n- `src/cmd/activate.rs#L1686-L1695` (this comment)\n- `src/cmd/deactivate.rs#L1599-L1605`\n- `tests/activate_test.rs#L612-L621`\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/activate.rs` around lines 1686 - 1695, Make the confirmation helper’s\nTTY state injectable instead of reading global std::io::stdin() directly, while\npreserving both auto-confirm and interactive behaviors. In src/cmd/activate.rs\nlines 1686-1695, remove the is_terminal guard and assert the non-TTY policy\nusing the injected state; make the equivalent unconditional assertion in\nsrc/cmd/deactivate.rs lines 1599-1605. In tests/activate_test.rs lines 612-621,\nremove the early return so the end-to-end assertion always executes, or mark the\ntest #[ignore] if it cannot run reliably.\n```\n\n
\n\n\n\n\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrr","isResolved":true,"isOutdated":false,"path":"src/cmd/setup.rs","line":39,"originalLine":39,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Find every user-facing string that suggests `setup nu --version`.\nrg -n --type=rust -C2 'setup nu' \necho '--- docs/markdown ---'\nrg -n 'setup nu' --glob '*.md'\necho '--- confirm version arg definition ---'\nast-grep run --pattern 'pub version: Option' --lang rust src/cmd/setup.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 355\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- setup definitions ---'\nsed -n '1,180p' src/cmd/setup.rs\n\necho '--- use hints ---'\nsed -n '1,150p' src/cmd/use_cmd.rs\n\necho '--- all setup nu references ---'\nrg -n -C2 'setup[[:space:]]+nu|--version' . --glob '!target/**' --glob '!Cargo.lock'\n\necho '--- CLI tests and command wiring ---'\nrg -n -C3 'Setup|setup|NuAction|version: Option|setup nu' src tests 2>/dev/null || true\n\necho '--- clap dependency and project test setup ---'\nrg -n -C2 '^clap|clap::|try_parse|parse_from|CommandFactory' Cargo.toml Cargo.lock src tests 2>/dev/null || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50380\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\npython3 - <<'PY'\nfrom pathlib import Path\nfor p in [Path(\"src/cmd/setup.rs\"), Path(\"src/cmd/use_cmd.rs\")]:\n print(f\"--- {p} ---\")\n for i, line in enumerate(p.read_text().splitlines(), 1):\n if p.name == \"setup.rs\" and 1 <= i <= 120:\n print(f\"{i:4}: {line}\")\n elif p.name == \"use_cmd.rs\" and 60 <= i <= 115:\n print(f\"{i:4}: {line}\")\nPY\n\necho '--- tracked files relevant to CLI behavior ---'\ngit ls-files | rg '(^|/)(main|cli|command|setup|use|test).*|Cargo.toml$' | head -100\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 8994\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- parser construction and existing parse assertions ---'\nrg -n -C5 'parse_from|try_parse_from|Cli::|setup.*nu|NuAction' tests src --glob '*.rs' | head -300\n\necho '--- CLI and Cargo configuration ---'\nsed -n '1,130p' src/cli.rs\nsed -n '1,120p' Cargo.toml\nrg -n 'name = \"clap\"|version = ' Cargo.lock | head -30\n\necho '--- remaining exact stale hints, excluding historical planning docs ---'\nrg -n -C1 --glob '*.rs' --glob '*.md' --glob '!docs/plans/**' --glob '!CHANGELOG.md' 'setup nu --version'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 21557\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\npython3 - <<'PY'\n# Check whether the hinted version values can equal the NuAction subcommand names.\n# This is a data-shape check, not a claim about clap's parser implementation.\nimport re\nnames = {\"remove\", \"path\", \"use\"}\nexamples = [\"0.113.1\", \"0.113.1-alpha.1\", \"latest\", \"remove\", \"path\", \"use\"]\nfor value in examples:\n semver_like = bool(re.fullmatch(r\"\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?\", value))\n print(f\"{value!r}: semver_like={semver_like}, collides={value in names}\")\nPY\n```\n\nLength of output: 389\n\n---\n\n🌐 Web query:\n\n`Clap Rust derive optional positional argument alongside optional subcommand parsing behavior`\n\n💡 Result:\n\nIn `clap` (Rust), combining optional positional arguments and optional subcommands often creates ambiguity because the parser must decide whether a given token is a value for the positional argument or the name of a subcommand [1][2]. ### Key Parsing Behavior 1. Subcommand Precedence: By default, `clap` often prioritizes subcommands over positional arguments when parsing [3]. If an input matches a registered subcommand name, `clap` will attempt to parse it as a subcommand regardless of whether a preceding positional argument is still expecting values [1][2]. 2. Positional Ambiguity: If your CLI design allows positional arguments to appear before subcommands, the parser may \"consume\" tokens meant for a subcommand as values for the positional argument, or vice versa, leading to unexpected errors or incorrect matches [4][3][5]. ### Recommended Approaches To manage this behavior, consider the following strategies based on your CLI requirements: * Use `subcommand_precedence_over_arg`: If you want the subcommand to be preferred even when a positional argument could technically accept the input, you can set `#[command(subcommand_precedence_over_arg = true)]` on your main struct [4][5]. * Structure Subcommands Properly: The most robust way to handle shared context is to move the shared arguments into the subcommand structs themselves [4]. If global access is required, define the argument with `global = true` so it is available to the parent and all child subcommands [6][7]. * Avoid Unbounded Positionals: Mixing unbounded positional arguments (e.g., `Vec`) with subcommands is a common source of bugs [4]. If you must use them, consider requiring the `--` separator to explicitly terminate positional arguments before providing a subcommand [1][5]. * Use `Option`: As with any optional argument or subcommand in `clap`'s `derive` API, wrapping the field in `Option` is the standard way to denote that the element is not required [8]. ### Troubleshooting * If the CLI fails to parse correctly, check if your positional argument name matches a subcommand name [3]. This is a frequent cause of \"unexpected argument\" errors [3]. * Utilize `arg_required_else_help` or `subcommand_required` if you need to enforce a specific structure, but be aware these settings can change how `clap` reacts to partial input [9][4][5]. For complex cases where the ambiguity cannot be resolved by standard settings, consider refactoring the CLI design to avoid overlapping name spaces between positional arguments and subcommand identifiers [4][6].\n\nCitations:\n\n- 1: https://docs.rs/clap/latest/src/clap/_concepts.rs.html\n- 2: https://docs.rs/clap/latest/i686-pc-windows-msvc/clap/_concepts/index.html\n- 3: https://github.com/clap-rs/clap/issues/5513\n- 4: https://github.com/clap-rs/clap/issues/3721\n- 5: https://github.com/clap-rs/clap/issues/4238\n- 6: https://github.com/clap-rs/clap/issues/5690\n- 7: https://github.com/clap-rs/clap/discussions/2260\n- 8: https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html\n- 9: https://docs.rs/clap/latest/clap/_tutorial/index.html\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- complete CLI parse tests ---'\nsed -n '150,230p' tests/setup_nu_test.rs\nsed -n '250,265p' tests/setup_nu_test.rs\n\necho '--- setup execution dispatch ---'\nsed -n '150,215p' src/cmd/setup.rs\n\necho '--- version-hint call sites and user-facing text ---'\nrg -n -C1 --glob '*.rs' --glob '*.md' --glob '!docs/plans/**' --glob '!CHANGELOG.md' \\\n 'setup_nu_version|setup nu --version|setup nu |setup nu \\[VERSION\\]|setup nu [0-9]'\n\necho '--- all source references to the affected use messages ---'\nrg -n -C2 'No Nu versions installed|is not installed' src tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 5524\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"/tmp/nonexistent\") # keep this verifier read-only and independent of the repository runtime\nprint(\"A semver version such as 0.113.1 is distinct from NuAction names: remove, path, use.\")\nprint(\"The repository's parser tests cover each NuAction name and reject an action plus a version.\")\nPY\n```\n\nLength of output: 302\n\n---\n\n\n\n
\n\n**Use the positional syntax in `numan use` hints.**\n\n`NuSetupArgs::version` is positional. Replace each `numan setup nu --version {}` hint in `src/cmd/use_cmd.rs` with `numan setup nu {}`. `numan setup nu 0.113.1` parses as an install, while `remove`, `path`, and `use` remain subcommands.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 33 - 39, Update every setup hint in the use\ncommand implementation to pass the Nushell version positionally, replacing the\n--version form with numan setup nu {} while preserving the existing version\nplaceholder and subcommand behavior.\n```\n\n
\n\n\n\n\n\n\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrv","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":207,"originalLine":207,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**`execute_nu_impl` mutates the root without the mutation lock when doctor calls it.**\n\n`execute_nu` acquires the lock at Line 156 and then delegates here. The doctor repair path does not. `src/cmd/doctor.rs` drops its lock at Line 1065 with `drop(lock.take())`, then calls `setup::execute_nu_impl` at Line 1079 and Line 1119.\n\n`execute_nu_impl` now dispatches to mutating actions that were not previously reachable this way:\n\n- `remove_managed_nu` → `std::fs::remove_dir_all` on the managed Nushell directory.\n- `execute_use_path` and `execute_use_existing` → `remove_managed_nu_if_present`, plus PATH persistence.\n- the default branch → `bootstrap::execute_nu_setup`, which writes `nu_state/active-version.json`.\n\nA concurrent `numan install` or `numan activate` can therefore run while doctor deletes the managed Nu binary those commands depend on.\n\nAcquire the lock inside `execute_nu_impl`, or give doctor a locked entry point.\n\n\n\n\n\nBased on coding guidelines: \"Every new mutating code path must acquire the mutation lock and snapshot the lockfile before writes.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 161 - 207, Ensure execute_nu_impl acquires the\nmutation lock and snapshots the lockfile before dispatching any mutating action,\nincluding remove_managed_nu, execute_use_path, execute_use_existing, and\nbootstrap::execute_nu_setup. Preserve the existing execute_nu lock behavior\nwithout double-locking by introducing a clearly separated locked entry point or\nadjusting the caller contract, and update the doctor repair path to use the\nappropriate entry point.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrx","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":191,"originalLine":191,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**The `--skip-path` conflict check is duplicated verbatim.**\n\nLines 173-178 and Lines 186-191 contain the identical bail message for the deprecated flag path and the subcommand path. Two copies of one invariant drift apart.\n\nExtract a small helper and call it from both branches.\n\n\n\n\n
\n♻️ Proposed refactor\n\n```diff\n+fn reject_skip_path_for_use(skip_path: bool) -> Result<()> {\n+ if skip_path {\n+ bail!(\n+ \"numan setup nu use cannot be combined with --skip-path. \\\n+ Off-PATH registration must persist the binary directory to PATH.\"\n+ );\n+ }\n+ Ok(())\n+}\n```\n\nThen replace both inline blocks with `reject_skip_path_for_use(args.skip_path)?;`.\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\nfn reject_skip_path_for_use(skip_path: bool) -> Result<()> {\n if skip_path {\n bail!(\n \"numan setup nu use cannot be combined with --skip-path. \\\n Off-PATH registration must persist the binary directory to PATH.\"\n );\n }\n Ok(())\n}\n\n if args.skip_path {\n reject_skip_path_for_use(args.skip_path)?;\n }\n return execute_use_existing(existing, args.yes, root);\n }\n\n match &args.action {\n Some(NuAction::Remove) => remove_managed_nu(root, args.yes),\n Some(NuAction::Path) => execute_use_path(args.yes, root),\n Some(NuAction::Use { path }) => {\n reject_skip_path_for_use(args.skip_path)?;\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 173 - 191, Extract the duplicated --skip-path\nvalidation into a small helper such as reject_skip_path_for_use, preserving the\nexisting bail message and error behavior. Replace both inline checks in the\ndeprecated flag path and the NuAction::Use branch with calls to the helper,\npassing args.skip_path and propagating its result.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJr6","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":252,"originalLine":252,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🔴 Critical_ | _⚡ Quick win_\n\n**`setup nu use ` deletes the working managed install before it validates the replacement.**\n\nLine 243 calls `remove_managed_nu_if_present`, which runs `std::fs::remove_dir_all` on `tools/nushell`. Only afterwards does Line 250 call `bootstrap::register_existing_nu`, and that function rejects the input for three separate reasons (`src/nu/bootstrap.rs` Lines 444-455):\n\n- `canonicalize` fails when the path does not exist.\n- `resolved.is_file()` is false for a directory.\n- `validate_nushell_binary` fails when the file is not a runnable Nushell.\n\nSo `numan setup nu use /typo/nu` destroys a working managed installation and then returns an error. The user is left with no managed Nu and no replacement. `execute_use_path` at Line 231 has the same ordering, and `validate_nushell_binary` can still fail there.\n\nValidate and register first. Remove the managed install only after the replacement is proven good.\n\n\n\n\n
\n🛡️ Proposed fix: validate before deleting\n\n```diff\n fn execute_use_existing(path: &Path, yes: bool, root: &Path) -> Result<()> {\n- remove_managed_nu_if_present(root)?;\n let options = NuSetupOptions {\n yes,\n force: false,\n skip_path: false,\n version: None,\n };\n- bootstrap::register_existing_nu(path, &options)?;\n+ // Validate and register the replacement first. Only then discard the\n+ // managed install, so a bad path cannot destroy a working setup.\n+ bootstrap::register_existing_nu(path, &options)?;\n+ remove_managed_nu_if_present(root)?;\n Ok(())\n }\n```\n\nApply the same reordering in `execute_use_path` at Lines 231-238.\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\nfn execute_use_existing(path: &Path, yes: bool, root: &Path) -> Result<()> {\n let options = NuSetupOptions {\n yes,\n force: false,\n skip_path: false,\n version: None,\n };\n // Validate and register the replacement first. Only then discard the\n // managed install, so a bad path cannot destroy a working setup.\n bootstrap::register_existing_nu(path, &options)?;\n remove_managed_nu_if_present(root)?;\n Ok(())\n}\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 242 - 252, Update execute_use_existing and\nexecute_use_path to call bootstrap::register_existing_nu before\nremove_managed_nu_if_present, so canonicalization, type checks, and\nvalidate_nushell_binary complete before deleting the managed installation.\nPreserve the existing options and error propagation, and remove the managed\ninstall only after registration succeeds.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJr-","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":301,"originalLine":301,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n**This deletes every installed Nu version and leaves `active-version.json` dangling.**\n\n`bootstrap::managed_nu_dir` returns `root/tools/nushell`, and `version_manager::versioned_nu_dir` returns the same path. After this PR that directory is the parent of every side-by-side version. `remove_dir_all` at Line 291 therefore removes all installed versions, not just one legacy binary.\n\nThe function also leaves `nu_state/active-version.json` in place. `version_manager::active_nu_binary` then reads a version whose binary no longer exists, returns `None`, and `bootstrap::managed_nu_binary` silently falls back to the legacy path. The recorded active version and the filesystem disagree, with no error and no warning.\n\n`remove_managed_nu` at Lines 255-285 has the same stale-state problem.\n\nClear the active-version marker whenever the versioned tree is removed. Also state in the message which versions were removed, so the deletion is not silent.\n\nThe message at Line 298 still names the deprecated `--use-existing` flag. Use the `setup nu use` subcommand name.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 288 - 301, Update remove_managed_nu_if_present\nand remove_managed_nu to clear the active-version marker whenever the\nmanaged/versioned Nushell tree is removed, keeping version_manager state\nconsistent with the filesystem. Ensure removal messaging identifies the versions\nremoved rather than implying a single legacy binary deletion, and replace the\ndeprecated --use-existing wording with the setup nu use subcommand name.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsA","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":375,"originalLine":375,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🔵 Trivial_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Does the vendored loader carry the ownership marker?\nfd -t f 'loader.nu' assets\necho '--- OWNERSHIP_MARKER definition ---'\nrg -n 'OWNERSHIP_MARKER' --type=rust\necho '--- first lines of the vendored loader ---'\nfd -t f 'loader.nu' assets --exec head -5 {}\necho '--- assert_managed_file_owned body ---'\nast-grep run --pattern 'pub fn assert_managed_file_owned($$$) { $$$ }' --lang rust src/util/fs_safety.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 227\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- relevant files ---'\nfd -t f 'setup.rs|fs_safety.rs|loader.nu' src assets\n\necho '--- setup loader and ownership call sites ---'\nrg -n -C 8 'install_loader_file|assert_managed_file_owned|VENDOR_LOADER|OWNERSHIP_MARKER|confirm_or_bail' src/cmd/setup.rs src/util/fs_safety.rs\n\necho '--- marker definitions and usages ---'\nrg -n -C 4 'OWNERSHIP_MARKER|managed_file|ownership' src assets\n\necho '--- vendored loader prefix ---'\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"assets/nushell-loader/loader.nu\")\ndata = p.read_bytes()\nprint(\"path:\", p)\nprint(\"first 160 bytes:\", repr(data[:160]))\nprint(\"first line:\", repr(data.splitlines()[0] if data.splitlines() else b\"\"))\nPY\n\necho '--- exact ownership helper body ---'\nsed -n '220,280p' src/util/fs_safety.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\npython3 - <<'PY'\nfrom pathlib import Path\n\nloader = Path(\"assets/nushell-loader/loader.nu\").read_bytes()\nmarker = b\"# Generated and managed by Numan. Do not edit.\\n# Numan autoload schema: 1\\n\"\nprint(\"loader_starts_with_ownership_marker:\", loader.startswith(marker))\nprint(\"loader_first_line:\", repr(loader.splitlines()[0] if loader.splitlines() else b\"\"))\nPY\n\npython3 - <<'PY'\nfrom pathlib import Path\ns = Path(\"src/cmd/setup.rs\").read_text()\nstart = s.index(\"fn install_loader_file\")\nend = s.index(\"\\n}\\n\", start) + 2\nbody = s[start:end]\nprint(\"ownership_check_after_vendored_compare:\", body.index(\"assert_managed_file_owned\") > body.index(\"if existing == VENDOR_LOADER\"))\nprint(\"ownership_check_before_write:\", body.index(\"assert_managed_file_owned\") < body.index(\"write_bytes_atomic\"))\nprint(\"nested_force_guard_present:\", \"if !args.force\" in body[body.index(\"assert_managed_file_owned\"):])\nPY\n\necho '--- focused setup tests ---'\nsed -n '490,535p' src/cmd/setup.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 1757\n\n---\n\n\n\n
\n\n**Remove the redundant guard and use a loader-specific error label.** `VENDOR_LOADER` already starts with `OWNERSHIP_MARKER`, so the ownership check accepts NumanΓÇÖs loader. If the loader is modified, the helper reports that `numan.nu` drifted. The inner `if !args.force` is also unreachable because the outer block already excludes `args.force`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 364 - 375, In the loader installation flow\naround assert_managed_file_owned, remove the redundant if !args.force guard and\nkeep confirmation directly under the existing non-force branch. Update the\nownership validation to use a loader-specific error label so modified loader\nfiles report loader drift rather than the numan.nu label.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsD","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":649,"originalLine":649,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Add a regression test for the `setup nu use ` ordering bug.**\n\nThese tests cover removal and legacy-flag delegation, and they correctly avoid spawning a real `nu`. The uncovered case is the destructive one: `execute_use_existing` with a path that fails validation must leave the managed install intact.\n\nAdd a test that seeds a managed directory, calls `execute_use_existing` with a nonexistent path, asserts the call returns an error, and asserts the managed directory still exists. That test fails today, which is the point.\n\n\n\n\n\nBased on coding guidelines: \"Behavior changes require corresponding tests, including relevant failure paths.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 589 - 649, Add a regression test alongside the\nexisting Nu setup tests for execute_use_existing: create a managed installation\nwith a fake nu.exe, invoke it with a nonexistent existing-path value, assert the\ncall returns an error, and verify the managed directory remains intact. Avoid\nspawning a real Nu and preserve the existing removal and delegation test\ncoverage.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsF","isResolved":false,"isOutdated":false,"path":"src/cmd/try_cmd.rs","line":23,"originalLine":23,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Describe the `--yes` safety boundary.**\n\nThe help says `--yes` skips confirmation prompts, but `offer_managed_nu_pin` deliberately rejects `yes` to prevent silent managed-Nu installation or switching. The test `offer_managed_nu_pin_yes_refuses_silent_switch` confirms this behavior.\n\nState that `--yes` does not consent to managed-Nu switching, or expose a separate explicit option.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/try_cmd.rs` at line 23, Update the `--yes` help text near\n`offer_managed_nu_pin` to state that it skips ordinary confirmation prompts but\ndoes not authorize silent managed-Nu installation or switching; preserve the\nexisting refusal behavior verified by\n`offer_managed_nu_pin_yes_refuses_silent_switch`.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsH","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":31,"originalLine":31,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**Acquire the mutation lock before migrating or writing active-version state.**\n\n`execute` is a new mutating code path: `migrate_legacy_install` renames the installed binary on disk, and every branch (`execute_list`, `execute_latest`, `execute_switch`) can write `active-version.json`. No call to `acquire_mutation_lock(root)` guards any of this.\n\nWithout the lock, a concurrent `numan use`, `install`, `update`, or `remove` can race with the binary rename or the active-version write. Acquire the mutation lock at the top of `execute`, matching the pattern required for other mutating commands.\n\n\n\nAs per coding guidelines: \"Every new mutating code path must acquire the mutation lock and snapshot the lockfile before writes.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` around lines 21 - 31, Update execute to acquire the\nmutation lock before calling migrate_legacy_install or dispatching to\nexecute_list, execute_latest, or execute_switch, and snapshot the lockfile\nbefore any of these writes, following the established mutating-command pattern\nand propagating setup errors.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsJ","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":24,"originalLine":24,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n**Don't discard the migration error silently.**\n\n`let _ = version_manager::migrate_legacy_install(root);` swallows the `Result`. The referenced implementation renames the legacy binary before calling `write_active_version`. If the rename succeeds but the subsequent write fails, the binary has already moved, yet the failure is invisible. The user gets no diagnostic and `numan use` continues as if there was nothing to migrate.\n\nLog or surface the error instead of discarding it.\n\n\n\n\n
\n🛠️ Proposed fix\n\n```diff\n- // Attempt migration of legacy single-binary install before any operation.\n- // This is a no-op if migration has already occurred or no legacy install exists.\n- let _ = version_manager::migrate_legacy_install(root);\n+ // Attempt migration of legacy single-binary install before any operation.\n+ // This is a no-op if migration has already occurred or no legacy install exists.\n+ if let Err(e) = version_manager::migrate_legacy_install(root) {\n+ eprintln!(\"Warning: failed to migrate legacy Nu install: {e:#}\");\n+ }\n```\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\n if let Err(e) = version_manager::migrate_legacy_install(root) {\n eprintln!(\"Warning: failed to migrate legacy Nu install: {e:#}\");\n }\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` at line 24, Update the migration call in the use command\nto handle the Result from version_manager::migrate_legacy_install instead of\ndiscarding it; log or propagate the error with a useful diagnostic, and ensure\nnuman use does not silently continue after migration failure.\n```\n\n
\n\n\n\n\n\n\n\n_Source: MCP tools_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsN","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":101,"originalLine":101,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Switching the active version doesn't refresh cached Nu paths or activation state.**\n\n`execute_latest` and `execute_switch` both call `version_manager::write_active_version` and stop there. Compare this to `src/cmd/nu_pin_offer.rs`'s `install_pinned_nu_and_refresh`, which explicitly calls `init::execute(&InitArgs { refresh: true }, root)` right after switching Nu.\n\nIf `activate`/`deactivate` load a cached `NuPaths` record and validate against the previously recorded executable hash/version, then after `numan use ` the cached record still points at the old Nu binary until the user separately runs `numan init --refresh`. This is a producer-consumer contract gap: `active-version.json` changes, but nothing tells the NuPaths cache to catch up.\n\nCall `init::execute(&InitArgs { refresh: true }, root)` after a successful switch (as `nu_pin_offer.rs` does), or explicitly document that users must run `numan init --refresh` after `numan use`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` around lines 55 - 101, After each successful version\nswitch in execute_latest and execute_switch, invoke init::execute with InitArgs\nconfigured with refresh: true, passing the same root, so cached NuPaths and\nactivation state immediately reflect the new active version. Preserve the\nexisting success messages and error propagation, and apply the refresh only\nafter write_active_version succeeds.\n```\n\n
\n\n\n\n\n\n\n\n_Source: MCP tools_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsP","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":101,"originalLine":101,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Description: Check whether version strings are validated before path construction.\nast-grep run --pattern 'fn version_install_dir($$$) { $$$ }' --lang rust src/nu/version_manager.rs\nast-grep run --pattern 'fn is_version_installed($$$) { $$$ }' --lang rust src/nu/version_manager.rs\nrg -n 'semver::Version::parse|is_ascii|\\.\\.' src/nu/version_manager.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 381\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -e\nprintf '%s\\n' '--- candidate files ---'\nfd -t f 'version_manager|use_cmd|AGENTS' . | sort\nprintf '%s\\n' '--- version manager symbols and relevant source ---'\nast-grep outline src/nu/version_manager.rs 2>/dev/null || true\nsed -n '1,280p' src/nu/version_manager.rs\nprintf '%s\\n' '--- use command and helper call sites ---'\nsed -n '1,150p' src/cmd/use_cmd.rs\nrg -n 'version_install_dir|is_version_installed|write_active_version|active-version|parse_version|normalize' src\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 22597\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -e\nprintf '%s\\n' '--- active binary consumers and version inputs ---'\nrg -n -C 3 'active_nu_binary\\(|version_binary\\(|version_install_dir\\(|write_active_version\\(' src\nprintf '%s\\n' '--- setup/bootstrap version handling ---'\nsed -n '80,125p' src/nu/bootstrap.rs\nsed -n '200,250p' src/nu/bootstrap.rs\nprintf '%s\\n' '--- behavioral path probe (std::path semantics) ---'\npython3 - <<'PY'\nfrom pathlib import PurePosixPath\nroot = PurePosixPath('/app')\nbase = root / 'tools' / 'nushell'\nfor version in ('0.113.1', '../../etc', '../../../etc', '..', 'a/../../../../outside'):\n # Rust PathBuf::join preserves lexical components; display the joined path.\n joined = base / version / 'nu'\n print(f'{version!r} -> {joined}')\nPY\nprintf '%s\\n' '--- tests and validation references ---'\nrg -n -C 2 'Version::parse|semver|version.*(validate|invalid)|invalid.*version|version.*separator' src tests Cargo.toml 2>/dev/null || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 41815\n\n---\n\n\n\n
\n\n**Reject path-like Nu version identifiers before filesystem use.** `version_install_dir` joins `version` without validation, so `../../../etc` can resolve outside `/tools/nushell`; `active_nu_binary` can later return that path. The existing semver parsing only sorts directory names and parses Nu output. Validate the value at the shared version-manager boundary and add traversal tests.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` around lines 73 - 101, Validate Nu version identifiers at\nthe shared version-manager boundary before any filesystem path construction,\nrejecting path traversal and other path-like values while preserving valid\nversion handling. Update the relevant version-manager functions used by\nexecute_switch, including version_install_dir and active_nu_binary, to rely on\nthis validation, and add tests covering traversal inputs such as ../../../etc\nand valid versions.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsS","isResolved":false,"isOutdated":false,"path":"src/core/resolve.rs","line":317,"originalLine":317,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Correct the `append_nu_mismatch_remediation` documentation.**\n\nThe comment says the message states that PATH is untouched. The message now states only that the existing Nu is not replaced. `setup nu` can update PATH unless `--skip-path` is used, as documented in README.md Line 353. Describe the actual message contract.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/core/resolve.rs` at line 317, Update the documentation comment for\nappend_nu_mismatch_remediation to accurately describe that the message says the\nexisting Nu installation is not replaced, rather than claiming PATH is\nuntouched; retain the documented setup nu context and closing\nbehavior.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsY","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":52,"originalLine":52,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**`if let Ok(Some(..))` hides a corrupt active-version marker.**\n\n`version_manager::active_nu_binary` returns `Err` when `nu_state/active-version.json` exists but cannot be read or parsed (`read_active_version`, `src/nu/version_manager.rs` Lines 33-37). Line 47 discards that error and falls through to the legacy path.\n\nThe result: a corrupt or truncated marker makes Numan silently resolve a different Nu binary. `src/cmd/doctor.rs` uses `managed_nu_binary` at Line 461 and Line 1547, so even `numan doctor` reports a healthy-looking path instead of the real fault. That is fake readiness.\n\nThe `PathBuf` return type cannot carry the error. Add a fallible variant and use it where a diagnostic matters, or at minimum emit the error to stderr before falling back.\n\n\n\n\n\nBased on coding guidelines: \"Error paths must return `anyhow::Result` with context\" and \"Treat the lockfile as the authoritative source of truth; derived projections such as autoload state are not authoritative.\"\n\n
\n🛡️ Minimum fix: surface the error\n\n```diff\n pub fn managed_nu_binary(root: &Path) -> PathBuf {\n- // Try to read the active version; if set, return the versioned binary path.\n- if let Ok(Some(binary)) = crate::nu::version_manager::active_nu_binary(root) {\n- return binary;\n- }\n+ // Try to read the active version; if set, return the versioned binary path.\n+ match crate::nu::version_manager::active_nu_binary(root) {\n+ Ok(Some(binary)) => return binary,\n+ Ok(None) => {}\n+ // A corrupt marker must not resolve silently to a different binary.\n+ Err(e) => eprintln!(\"warning: failed to read active Nu version: {e:#}\"),\n+ }\n // Fallback to legacy path (for backward compat or when no active version is set).\n managed_nu_dir(root).join(nu_binary_name())\n }\n```\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\n/// Returns the path to the currently active Nu binary.\n///\n/// This delegates to `version_manager::active_nu_binary()` which reads the active\n/// version marker and returns the versioned binary path.\npub fn managed_nu_binary(root: &Path) -> PathBuf {\n // Try to read the active version; if set, return the versioned binary path.\n match crate::nu::version_manager::active_nu_binary(root) {\n Ok(Some(binary)) => return binary,\n Ok(None) => {}\n // A corrupt marker must not resolve silently to a different binary.\n Err(e) => eprintln!(\"warning: failed to read active Nu version: {e:#}\"),\n }\n // Fallback to legacy path (for backward compat or when no active version is set).\n managed_nu_dir(root).join(nu_binary_name())\n}\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 41 - 52, Update managed_nu_binary and its\ncallers so errors from version_manager::active_nu_binary, including corrupt\nactive-version markers, are surfaced instead of silently falling back to the\nlegacy path. Prefer adding a fallible variant returning anyhow::Result with\ncontext, then use it in diagnostic paths such as doctor; if the infallible API\nmust remain, emit the underlying error to stderr before applying the fallback.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsb","isResolved":true,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":227,"originalLine":227,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🔴 Critical_ | _⚡ Quick win_\n\n**The version string reaches a filesystem join with no validation.**\n\nLine 225 and Line 227 pass `version` straight into `version_install_dir` and `version_binary`, which call `Path::join`. `version` comes from the CLI positional in `src/cmd/setup.rs` Line 37-39 and flows through `NuSetupOptions.version`.\n\n`Path::join` does not sanitise. A value such as `../../../evil` produces a destination outside `tools/nushell`, and an absolute value replaces the join base entirely. The install then writes an executable outside `$NUMAN_ROOT`.\n\nValidate the version as a strict semantic version before any path join. See the consolidated comment for the shared fix in `src/nu/version_manager.rs`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 224 - 227, Validate NuSetupOptions.version\nas a strict semantic version before invoking version_install_dir or\nversion_binary in the bootstrap flow. Reuse the shared validation fix and\nsymbols from src/nu/version_manager.rs, reject invalid or path-like values\nearly, and preserve the existing installation behavior for valid versions.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsd","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":240,"originalLine":240,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Archive installation now writes Nu integration state.**\n\nLine 240 calls `write_active_version`, so `install_from_archive` silently switches the active Nu as a side effect of unpacking an archive. That crosses the boundary this repository draws: installation writes payloads, and only the activation lifecycle owns integration state.\n\nThe concrete consequence is state that disagrees with itself. `NuPaths` still records the previous executable and hash. `activate` loads that cached identity and calls `validate_drift`, so the next `numan activate` fails until the user runs `numan init --refresh`. Line 722 prints that hint, which means the code already knows it left the root inconsistent.\n\nReturn the installed version to the caller and let the setup command decide whether to activate it. Then the install step stays a pure payload write.\n\nAlso note the unit test at Lines 876-878 asserts `installed == managed_nu_binary(root)`. That assertion passes only because of this hidden activation write, so the test cannot distinguish the two concerns.\n\n\n\n\n\nBased on coding guidelines: \"Only `activate` and `deactivate` may modify Nu integration state.\" and \"`numan install` must write only to `$NUMAN_ROOT` and must not invoke Nu or register plugins/autoloads.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 237 - 240, Remove the write_active_version\ncall from install_from_archive so archive installation only writes payload files\nand VERSION metadata. Return the installed version from install_from_archive,\nthen have the setup/activation command explicitly activate that returned version\nthrough the existing activation lifecycle. Update the unit test around installed\nand managed_nu_binary to verify installation without relying on hidden\nactive-version state, preserving the rule that only activate and deactivate\nmodify Nu integration state.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJse","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":467,"originalLine":467,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n**Non-TTY auto-confirm now approves a persistent PATH change.**\n\n`options.yes` is `false` on the `setup nu use` and `setup nu path` paths (`src/cmd/setup.rs` Lines 232-237 and 244-249 set `yes` from the caller, and doctor passes `true`). When `yes` is `false` and stdin is not a terminal, `confirm_or_bail` auto-confirms. Line 471 then calls `persist_path_dir`, which edits the user shell profile or the Windows user PATH.\n\nA PATH edit is externally visible and awkward to reverse. It needs explicit consent, not an inferred one. See the consolidated comment.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 459 - 467, Update the confirmation flow\nsurrounding confirm_or_bail in the PATH setup logic so non-TTY execution cannot\nimplicitly approve persist_path_dir when options.yes is false. Require an\nexplicit affirmative confirmation for interactive PATH changes, while preserving\nthe existing options.yes behavior for callers that intentionally pass true.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Learnings_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsf","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":16,"originalLine":16,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Is nu_state part of the declared root layout, and does doctor know about it?\nrg -n 'LAYOUT_DIRS' --type=rust -A15\necho '--- other nu_state references ---'\nrg -n 'nu_state' --type=rust\necho '--- documented root layout ---'\nrg -n 'tools/nushell|state/' --glob '*.md' | head -40\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 159\n\n---\n\n🏁 Script executed:\n\n```shell\nset -u\nprintf '%s\\n' '--- tracked Rust files ---'\ngit ls-files '*.rs' | sed -n '1,120p'\nprintf '%s\\n' '--- layout and state references ---'\nrg -n -C 4 'LAYOUT_DIRS|active-version|nu_state|root\\.join\\(\"state\"\\)|state/' --glob '*.rs' . || true\nprintf '%s\\n' '--- relevant files ---'\nfor f in src/nu/version_manager.rs src/util/fs_safety.rs src/cmd/doctor.rs; do\n if [ -f \"$f\" ]; then\n echo \"--- $f ---\"\n cat -n \"$f\" | sed -n '1,240p'\n fi\ndone\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50379\n\n---\n\n🏁 Script executed:\n\n```shell\nprintf '%s\\n' '--- version manager ---'\ncat -n src/nu/version_manager.rs | sed -n '1,240p'\nprintf '%s\\n' '--- doctor layout and Nu-state checks ---'\ncat -n src/cmd/doctor.rs | sed -n '250,430p'\nprintf '%s\\n' '--- active-version call sites ---'\nrg -n -C 5 'active_version|ActiveVersion|load_active|set_active|active-version' --glob '*.rs' . || true\nprintf '%s\\n' '--- doctor tests for active-version and layout ---'\nrg -n -C 5 'active.version|active-version|layout\\.nu_state|nu_state' tests/doctor_test.rs src/cmd/doctor.rs || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 32348\n\n---\n\n🏁 Script executed:\n\n```shell\nprintf '%s\\n' '--- doctor check pipeline ---'\nrg -n 'fn (check_|collect_|execute|run)|check_[a-z_]+\\(' src/cmd/doctor.rs | sed -n '1,180p'\nprintf '%s\\n' '--- version-manager integration ---'\nrg -n 'version_manager|active_nu_binary|read_active_version|write_active_version|active-version' src --glob '*.rs'\nprintf '%s\\n' '--- doctor findings and repair code around the main execution path ---'\nrg -n -C 8 'check_root|check_nu_paths|LAYOUT_DIRS|repair|findings' src/cmd/doctor.rs | sed -n '1,260p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 13533\n\n---\n\n\n\n
\n\n**Add an active-version consistency finding to `numan doctor`.** `nu_state` is already included in `LAYOUT_DIRS`, but `run_checks_with_options` never validates `active-version.json`. Report malformed markers and markers whose `version_binary(root, version)` is missing; allow an absent marker when no version is installed.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 14 - 16, Extend\nrun_checks_with_options with an active-version consistency check for the path\nreturned by active_version_path: tolerate an absent marker when no version is\ninstalled, but report findings for malformed JSON and for markers whose\nversion_binary(root, version) does not exist. Reuse the existing doctor\nfinding/reporting conventions and version discovery logic.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsh","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":38,"originalLine":38,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n**The doc comment promises `None` for an invalid marker, but the code returns `Err`.**\n\nLines 26-27 state: \"Returns `None` if no active version is set (marker doesn't exist or is invalid).\" Line 35-36 returns `Err` on a `serde_json` parse failure, and Line 33-34 returns `Err` on a read failure. Only the missing-file case yields `None`.\n\nThis mismatch already produced a defect. `src/nu/bootstrap.rs` Line 47 writes `if let Ok(Some(binary))`, which discards the parse error and silently falls back to the legacy path.\n\nFix the doc comment to describe the real contract. An unparseable marker is corrupt state and deserves an error, so keep the behaviour and correct the words.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 25 - 38, Update the doc comment for\nread_active_version to state that None is returned only when the marker file is\nmissing, while read failures and invalid JSON return errors. Preserve the\nexisting implementation behavior and error contexts.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsi","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":71,"originalLine":71,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🔴 Critical_ | _⚡ Quick win_\n\n**Validate the version string before you join it into a path.**\n\n`version_install_dir` at Line 64 joins the caller-supplied `version` with no validation, and `version_binary` builds on it. `Path::join` treats `..` as a real parent traversal and lets an absolute component discard the base entirely.\n\nThe reachable write primitive is the install path. `src/nu/bootstrap.rs` Lines 225-238 use these helpers to `create_dir_all`, `std::fs::copy` a binary, `make_executable`, and write a `VERSION` file. The `version` value arrives from the CLI positional in `src/cmd/setup.rs` Lines 37-39. So `numan setup nu ../../../../tmp/pwn` writes an executable outside `$NUMAN_ROOT`. `numan use ` reaches `write_active_version` and `is_version_installed` with the same unvalidated string.\n\nValidate once, here, and reject anything that is not a plain semantic version. Do it in the helper rather than at each call site, so a future caller cannot reintroduce the hole.\n\nTwo smaller points in the same segment. `versioned_nu_dir` at Line 58-60 returns the same path as `bootstrap::managed_nu_dir`; two names for one directory is how the \"remove the managed directory and lose every version\" behaviour stayed invisible. The binary-name expression at Line 69 is repeated at Line 102 and duplicates `bootstrap::nu_binary_name()`; collapse the three into one.\n\n\n\n\n\nBased on coding guidelines: \"Identify and fix security issues when encountered, including injection, XSS, SQL injection, path traversal, and secret leakage.\" and \"`numan install` must write only to `$NUMAN_ROOT`\".\n\n
\n🛡️ Proposed fix: reject non-semver version components\n\n```diff\n+/// Validate a version string that will become a path component.\n+///\n+/// Rejects traversal, separators, and absolute components before any join.\n+pub fn validate_version_component(version: &str) -> Result<()> {\n+ if semver::Version::parse(version).is_err() {\n+ bail!(\"Invalid Nu version '{version}'. Expected a semantic version such as 0.113.1.\");\n+ }\n+ Ok(())\n+}\n+\n /// Directory for a specific Nu version install.\n-pub fn version_install_dir(root: &Path, version: &str) -> PathBuf {\n- versioned_nu_dir(root).join(version)\n-}\n+pub fn version_install_dir(root: &Path, version: &str) -> Result {\n+ validate_version_component(version)?;\n+ Ok(versioned_nu_dir(root).join(version))\n+}\n \n /// Binary path for a specific Nu version.\n-pub fn version_binary(root: &Path, version: &str) -> PathBuf {\n- let binary_name = if cfg!(windows) { \"nu.exe\" } else { \"nu\" };\n- version_install_dir(root, version).join(binary_name)\n-}\n+pub fn version_binary(root: &Path, version: &str) -> Result {\n+ Ok(version_install_dir(root, version)?.join(nu_binary_name()))\n+}\n```\n\nNote that `semver::Version::parse` rejects a bare `0.113`, so confirm every real Nushell release tag parses before you adopt this exact predicate.\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 57 - 71, Update version_install_dir\nto validate version as a plain semantic version before joining it to\nversioned_nu_dir, rejecting traversal, absolute paths, and invalid versions;\nensure callers such as version_binary, bootstrap, and setup propagate the\nrejection. Consolidate versioned_nu_dir with bootstrap::managed_nu_dir and reuse\nbootstrap::nu_binary_name for version_binary instead of duplicating the\ndirectory and binary-name logic. Confirm the validator accepts every supported\nNushell release tag.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsk","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":86,"originalLine":86,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**A dangling active-version marker is indistinguishable from no marker.**\n\nLines 81-85 return `Ok(None)` both when no version was ever selected and when the marker names a version whose binary was deleted. The caller cannot tell a clean root from a broken one.\n\nThat gap is load-bearing. `src/nu/bootstrap.rs` Lines 45-51 treat `None` as \"fall back to the legacy path\", so a root whose active version was removed by `setup nu use` or `setup nu remove` resolves a different Nu binary and reports no problem.\n\nReturn a value that separates the two states, or add a dedicated check that doctor can surface as a finding.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 76 - 86, Update active_nu_binary to\ndistinguish an absent active-version marker from a marker whose version_binary\nno longer exists, preserving Ok(None) only for the no-marker case. Propagate the\nbroken active-version state through the caller in bootstrap and ensure doctor\ncan surface it instead of falling back silently to the legacy binary; use the\nexisting Result/error or diagnostic conventions in the surrounding\nversion-management code.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsl","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":118,"originalLine":118,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🌐 Web query:\n\n`Rust sort_by panic user-provided comparison function does not correctly implement a total order`\n\n💡 Result:\n\nIn Rust, the `sort` and `sort_by` methods require that the comparison logic defines a total order [1][2][3]. If a provided comparison function or the `Ord` implementation for a type violates this requirement, the sorting algorithm may panic [1][2][3]. The requirement for a total order means that for all elements `a`, `b`, and `c`, the following properties must hold: 1. Totality: Exactly one of `a < b`, `a == b`, or `a > b` is true [4][5]. 2. Transitivity: If `a < b` and `b < c`, then `a < c` [4][5]. The same must apply to equality and \"greater than\" relationships [4][5]. Starting with Rust 1.81, the standard library's sorting implementations include checks that can detect when these invariants are broken [1][6]. When such a violation is detected during execution, the library is permitted to panic [1][3]. Even if the function does not panic, if the total order is violated, the resulting order of the elements is unspecified, and any reliance on a specific sorted outcome is incorrect [1][2][3]. This behavior is a deliberate design choice to enforce correctness [7]. Because the sorting algorithms rely on these mathematical invariants to function efficiently and correctly, failing to provide a total order is considered a logic error [4][5]. The panic serves as a signal to the developer that their comparison logic is flawed [6][7]. If you are working with types that do not naturally have a total order (such as floating-point numbers), you must use provided alternatives like `f32::total_cmp` to create a valid total ordering [4][8].\n\nCitations:\n\n- 1: https://github.com/rust-lang/rust/issues/129561\n- 2: https://github.com/rust-lang/rust/issues/136665\n- 3: https://github.com/petrochenkov/rust/commit/8a8c0e25c9d4e242d7caa9b993dee0d02025e485\n- 4: https://doc.rust-lang.org/stable/std/cmp/trait.Ord.html\n- 5: https://doc.rust-lang.org/nightly/core/cmp/trait.Ord.html\n- 6: https://github.com/rust-lang/rust/pull/128273\n- 7: https://users.rust-lang.org/t/why-sort-need-t-to-be-ord/69815\n- 8: https://doc.rust-lang.org/std/cmp/trait.Ord.html\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\nprintf '%s\\n' '--- target function ---'\nsed -n '70,130p' src/nu/version_manager.rs\nprintf '%s\\n' '--- package metadata and call sites ---'\nrg -n -C 3 'rust-version|list_installed_versions|sort_by' Cargo.toml src tests || true\nprintf '%s\\n' '--- repository status ---'\ngit diff --stat\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 13245\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"src/nu/version_manager.rs\")\ntext = p.read_text()\nstart = text.index(\"fn list_installed_versions\")\nend = text.find(\"\\n}\", start) + 2\nprint(text[start:end])\nPY\nprintf '%s\\n' '--- metadata and references ---'\nrg -n -C 3 'rust-version|list_installed_versions|sort_by' Cargo.toml src tests || true\nprintf '%s\\n' '--- relevant file status ---'\ngit diff --stat -- src/nu/version_manager.rs Cargo.toml\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 12227\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -eu\ncat > /tmp/check_sort.rs <<'RS'\nuse std::cmp::Ordering;\n\nfn cmp(a: &str, b: &str) -> Ordering {\n match (semver(a), semver(b)) {\n (Some(a), Some(b)) => b.cmp(&a),\n _ => b.cmp(a),\n }\n}\n\nfn semver(s: &str) -> Option<(u64, u64, u64)> {\n let mut it = s.split('.');\n let v = (it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?);\n if it.next().is_some() { None } else { Some(v) }\n}\n\nfn main() {\n let values = [\"0.2.0\", \"0.10.0\", \"bad\"];\n for a in values {\n for b in values {\n println!(\"{a:?} vs {b:?}: {:?}\", cmp(a, b));\n }\n }\n let a = \"0.10.0\";\n let b = \"0.2.0\";\n let c = \"bad\";\n println!(\"transitivity check: cmp(a,b)={:?}, cmp(b,c)={:?}, cmp(a,c)={:?}\",\n cmp(a,b), cmp(b,c), cmp(a,c));\n}\nRS\nrustc /tmp/check_sort.rs -o /tmp/check_sort\n/tmp/check_sort\n```\n\nLength of output: 169\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -eu\nrustc --version\ncat >/tmp/check_inconsistent_sort.rs <<'RS'\nuse std::cmp::Ordering;\n\nfn parse(s: &str) -> Option<(u64, u64, u64)> {\n let mut it = s.split('.');\n let v = (it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?);\n if it.next().is_some() { None } else { Some(v) }\n}\n\nfn cmp(a: &str, b: &str) -> Ordering {\n match (parse(a), parse(b)) {\n (Some(a), Some(b)) => b.cmp(&a),\n _ => b.cmp(a),\n }\n}\n\nfn main() {\n let mut values = vec![\"0.2.0\", \"0.10.0\", \"bad\"];\n values.sort_by(|a, b| cmp(a, b));\n println!(\"{values:?}\");\n}\nRS\nrustc /tmp/check_inconsistent_sort.rs -o /tmp/check_inconsistent_sort\n/tmp/check_inconsistent_sort\n```\n\nLength of output: 168\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -eu\npython3 - <<'PY'\nfrom functools import cmp_to_key\nfrom itertools import permutations\nfrom enum import IntEnum\n\nclass O(IntEnum):\n LESS = -1\n EQUAL = 0\n GREATER = 1\n\ndef semver(s):\n parts = s.split(\".\")\n if len(parts) != 3 or not all(p.isdigit() for p in parts):\n return None\n return tuple(map(int, parts))\n\ndef cmp(a, b):\n av, bv = semver(a), semver(b)\n if av is not None and bv is not None:\n return (bv > av) - (bv < av) # b.cmp(a), descending semver\n return (b > a) - (b < a) # b.cmp(a), reverse lexicographic\n\nvalues = [\"0.10.0\", \"0.2.0\", \"0.15.0x\"]\nprint(\"pairwise comparisons (negative means left sorts first):\")\nfor a, b in permutations(values, 2):\n print(f\"{a} vs {b}: {cmp(a, b):+d}\")\n\na, b, c = values\nprint(\"cycle:\", f\"{a} < {b}:\", cmp(a, b) < 0,\n f\"{b} < {c}:\", cmp(b, c) < 0,\n f\"{c} < {a}:\", cmp(c, a) < 0)\nprint(\"sorted permutations:\")\nfor order in permutations(values):\n actual = list(order)\n actual.sort(key=cmp_to_key(cmp))\n print(order, \"=>\", actual)\nPY\n```\n\nLength of output: 791\n\n---\n\n🌐 Web query:\n\n`site:doc.rust-lang.org/std/primitive.slice.html sort_by comparator total order may panic`\n\n💡 Result:\n\nIn Rust, the sort_by method requires that the provided comparator function defines a total order over the elements being sorted [1][2]. While the official documentation for slice methods does not explicitly state that violating this requirement will trigger a panic, it does define the behavior of sorting functions in terms of the provided closure [1][2]. Specifically, if the provided closure panics, the sorting function will also panic [1][2]. When a comparator fails to implement a total order (for example, by returning inconsistent results for the same pair of elements or violating transitivity), the sorting algorithm may encounter unexpected states, which can lead to logical errors, infinite loops, or undefined behavior, though it does not necessarily guarantee an immediate panic. Developers must ensure that the comparator satisfies the properties of a total order—specifically reflexivity, antisymmetry, transitivity, and totality—to ensure correct and safe execution of the sort.\n\nCitations:\n\n- 1: https://doc.rust-lang.org/std/primitive.slice.html?search=sort\n- 2: https://doc.rust-lang.org/std/primitive.slice.html\n\n---\n\n\n\n
\n\n**Use a consistent ordering for all version names.**\n\nThe comparator mixes semantic-version and lexicographic ordering. `0.10.0`, `0.2.0`, and invalid name `0.15.0x` create a comparison cycle. `sort_by` may panic or return an unspecified order. Parse each version once, sort parsed versions first, and sort unparsable names separately. Add error context to directory-entry failures and a regression test.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 109 - 118, Update the version-sorting\nflow in the surrounding version manager function to parse each name once,\npartition valid semantic versions from unparsable names, sort valid versions by\nsemantic version descending and invalid names lexicographically using separate\ngroups, then combine them without a mixed comparator. Add contextual error\ninformation when reading directory entries, and add a regression test covering\nvalid and invalid names such as 0.10.0, 0.2.0, and 0.15.0x.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsn","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":160,"originalLine":160,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**Migration reads then renames with no mutation lock.**\n\nLines 146-160 check that the legacy binary exists and that no versioned directory exists. Line 195 renames the binary, and Line 204 writes the active-version marker. Nothing between those points holds a lock.\n\nA concurrent `numan setup nu` or a second `numan use` can pass the same check and then both act. The result is a lost binary or an active-version marker that names a directory the other process removed. This is a plain time-of-check to time-of-use window on the state that decides which Nu every later command runs.\n\nAcquire the mutation lock for the whole migration, and snapshot before the rename.\n\nTwo smaller points. Line 207 calls `.unwrap()` on `parent()`; library code should not panic even when the value is provably `Some`. That same line calls `remove_dir` on `tools/nushell`, which is the parent of the versioned directory you just created, so the call always fails and the error is discarded. Drop it or target the intended directory.\n\n\n\n\n\nBased on coding guidelines: \"Every new mutating code path must acquire the mutation lock and snapshot the lockfile before writes.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 145 - 160, Update the migration\nroutine containing the legacy_binary and versioned_nu_dir checks to acquire the\nmutation lock and snapshot the lockfile before any migration writes, holding the\nlock through the rename and active-version marker update. Replace the\nparent().unwrap() usage with non-panicking handling, and remove or correct the\nremove_dir call targeting tools/nushell so it does not silently attempt to\nremove the parent of the newly created versioned directory.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsp","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":179,"originalLine":179,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Migration spawns a Nu binary with no timeout, from a non-activation code path.**\n\nLines 163-171 execute the legacy binary and block on `output()`. There is no timeout, so a hung or non-terminating binary blocks the command forever. `output()` also reads stdout and stderr to EOF, so a binary that writes without exiting stalls the caller.\n\nTwo further concerns:\n\n- This runs outside the activation lifecycle. The repository restricts Nu invocation to the activate and deactivate boundary, and `numan use` is not that boundary.\n- The binary is whatever sits at `tools/nushell/nu`. If the root is writable by another process, migration executes it and trusts its stdout to pick a directory name that becomes a filesystem path.\n\nRead the version from the `VERSION` file when it exists, which `src/nu/bootstrap.rs` Line 238 already writes. If you must spawn, add a wall-clock timeout and make the step explicit to the user rather than implicit inside `numan use`.\n\n\n\n\n\nBased on coding guidelines: \"Only `activate` and `deactivate` may modify Nu integration state.\" and \"External-call hazards ... blocking calls without timeouts on request threads\".\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 162 - 179, Replace the legacy binary\nexecution in the version-detection flow with reading the existing VERSION file\nwritten by bootstrap, avoiding Nu invocation during numan use and preserving the\ndetected version behavior. If a fallback spawn remains necessary, isolate it\nbehind an explicit user-triggered step and enforce a wall-clock timeout before\ncollecting output; never trust unvalidated executable output as a filesystem\npath.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsq","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":317,"originalLine":317,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**The riskiest function in this module has no tests, because it cannot be tested as written.**\n\nThe suite covers the marker roundtrip, listing, the installed check, and version parsing. It does not cover `migrate_legacy_install`, `active_nu_binary`, or `latest_installed_version`.\n\n`migrate_legacy_install` spawns a real process at Line 163, so no unit test can exercise it without a real `nu` on disk. That is the signal to inject a seam. Take the version detector as a parameter, then a fake returns a canned version string and the test asserts the rename, the marker write, and the skip-when-versioned-dirs-exist branch.\n\nAlso add a `list_installed_versions` case that mixes a valid version with a non-version directory name such as `.tmp`. That case exercises the comparator path flagged at Lines 109-118, and it fails today.\n\n\n\n\n\nBased on coding guidelines: \"Unit tests must use injectable seams such as `FakeCandidateRunner` and registrars; do not spawn real `nu` in unit tests.\" and \"Behavior changes require corresponding tests, including relevant failure paths.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 237 - 317, Make\nmigrate_legacy_install testable by injecting its\nversion-detection/process-running dependency instead of spawning a real nu\nbinary, then add tests using a fake detector to verify directory renaming,\nactive-version marker creation, and skipping migration when versioned\ndirectories already exist. Extend test_list_installed_versions_with_versions\nwith a non-version directory such as .tmp alongside valid versions, preserving\nthe expected sorted valid-version output.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJss","isResolved":false,"isOutdated":false,"path":"src/util/confirm.rs","line":31,"originalLine":31,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🔴 Critical_ | _🏗️ Heavy lift_\n\n**Non-TTY auto-confirm removes consent from destructive and hard-to-reverse operations.** The shared root cause is `confirm_or_auto`: it returns `Ok(true)` whenever stdin is not a terminal, with no way for a caller to say \"this action is destructive, refuse instead\". Every caller inherits that single policy, so deletions and PATH edits now proceed unattended in any piped or CI invocation. Add an explicit non-TTY policy and route destructive callers through the refusing variant.\n- `src/util/confirm.rs#L12-L31`: add a non-TTY policy parameter, and bail with a message that points at `--yes` when the policy is to refuse.\n- `src/cmd/snapshot.rs#L242-L244`: pass the refusing policy, so `snapshot delete` and `snapshot rollback` cannot delete or roll back without an explicit `--yes`.\n- `src/nu/bootstrap.rs#L459-L467`: pass the refusing policy, so `persist_path_dir` never edits the user shell profile or Windows user PATH without an explicit `--yes`.\n\n
\n📍 Affects 3 files\n\n- `src/util/confirm.rs#L12-L31` (this comment)\n- `src/cmd/snapshot.rs#L242-L244`\n- `src/nu/bootstrap.rs#L459-L467`\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/util/confirm.rs` around lines 12 - 31, Update confirm_or_auto to accept\nan explicit non-TTY policy, refusing with a message directing users to --yes\nwhen configured to refuse while preserving auto-confirm behavior for other\ncallers; pass the refusing policy at src/util/confirm.rs:12-31,\nsrc/cmd/snapshot.rs:242-244, and src/nu/bootstrap.rs:459-467 so snapshot\ndelete/rollback and persist_path_dir require explicit --yes in non-TTY\nenvironments.\n```\n\n
\n\n\n\n\n\n\n\n\n\n\n\n_Source: Learnings_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJst","isResolved":false,"isOutdated":false,"path":"src/util/confirm.rs","line":57,"originalLine":57,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Tests cover only the `yes = true` short circuit.**\n\nBoth tests pass `yes = true`, which returns before the TTY check and the read. The non-TTY branch and the decline branch have no unit coverage here. The behavior change in this PR is exactly in those branches.\n\nExtract the decision into a testable seam that takes the TTY state and an input reader. Then assert the non-TTY policy and the `y`/`n`/empty-input parsing directly.\n\n\n\n\n\nBased on coding guidelines: \"Behavior changes require corresponding tests, including relevant failure paths.\" and \"Unit tests must use injectable seams such as `FakeCandidateRunner` and registrars\".\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/util/confirm.rs` around lines 44 - 57, Extend the confirm logic around\nconfirm_or_auto and confirm_or_bail by extracting the decision flow into an\ninjectable seam that accepts explicit TTY state and an input reader. Add unit\ntests covering the non-TTY policy plus y, n, and empty-input parsing, while\nretaining the existing yes=true short-circuit tests and behavior.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsu","isResolved":false,"isOutdated":false,"path":"src/util/hints.rs","line":37,"originalLine":37,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟡 Minor_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nrg -n -C 4 'setup_nu_use_existing|display\\(\\)|quote|escape' src tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- src/util/hints.rs ---'\ncat -n src/util/hints.rs\nprintf '%s\\n' '--- setup_nu_use_existing references ---'\nrg -n -C 8 'setup_nu_use_existing|setup nu use|--use-existing|CMD_SETUP' src tests Cargo.toml\nprintf '%s\\n' '--- relevant CLI definitions ---'\nrg -n -C 6 'UseExisting|use_existing|use-existing|Setup|setup' src/cli.rs src/main.rs src\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"src/util/hints.rs\")\nprint(\"hints.rs bytes:\", p.stat().st_size)\nprint(\"hints.rs lines:\", len(p.read_text().splitlines()))\nPY\nprintf '%s\\n' '--- exact function and callers ---'\nrg -n -C 12 'setup_nu_use_existing' src tests || true\nprintf '%s\\n' '--- command parsing references ---'\nrg -n -C 5 --glob '*.rs' 'use_existing|use-existing|SetupNu|Setup.*Nu|Nu.*Use' src tests || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 26669\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- tests/setup_nu_test.rs ---'\ncat -n tests/setup_nu_test.rs | sed -n '1,180p'\nprintf '%s\\n' '--- command/help text mentioning the hint ---'\nrg -n -C 8 'Nu not found|use-existing|setup nu' src tests docs README.md 2>/dev/null || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50379\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- doctor output rendering ---'\nrg -n -C 10 'fix|Finding|format.*report|print.*finding|findings' src/cmd/doctor.rs src | head -n 240\nprintf '%s\\n' '--- off-path resolution and allowed roots ---'\nrg -n -C 12 'fn resolve_off_path|resolve_off_path|discover_nu_off_path|known.*root|cargo/bin|LOCALAPPDATA' src\nprintf '%s\\n' '--- existing quoting/escaping helpers ---'\nrg -n -C 5 'shell.*quote|quote.*shell|escape.*shell|escape.*path|single.?quote|double.?quote|shellwords|shlex' src tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 39164\n\n---\n\n🌐 Web query:\n\n`Nushell official documentation quoting paths external commands semicolon metacharacters single quotes double quotes`\n\n💡 Result:\n\nIn Nushell, external commands, paths, quoting, and metacharacters behave according to specific rules designed for its unique architecture as a structured data shell. External Commands To run an external system command (a binary or application), you can call it directly by name. If the command name conflicts with a built-in Nushell command, or if you want to explicitly signal an external call, use the caret (^) sigil (e.g., ^ls) [1][2]. The run-external command provides a way to invoke external binaries, with support for features like redirecting stdout and stderr [3]. Nushell uses the PATH environment variable (automatically converted into a list) to locate these executables [2]. Paths Nushell handles paths as strings, often requiring quotes if they contain spaces or special characters (such as glob patterns) [4][5]. Using raw strings or single-quoted strings is recommended for paths, especially on Windows, as they preserve backslashes literally without needing escapes [6][5]. Semicolon Metacharacters A semicolon (;) acts as a command separator, similar to a newline in a Nushell expression [7]. It is not a POSIX-style logical operator for chaining commands based on success or failure [8]. When using a semicolon, the output of the preceding command is discarded, and the $in variable will not capture data from the previous statement [9]. Quoting Rules Nushell provides several ways to quote strings, each with distinct behaviors: - Single-quoted strings ('...'): Treat content as completely literal; they do not support escape sequences and are ideal for paths or simple strings [6][5]. - Double-quoted strings (\"...\"): Support C-style backslash escapes (e.g., \\n, \\t, \\\") and allow for variable interpolation using parentheses, such as \"Value is ($var)\" [6][10]. - Raw strings (r#'... '#): Allow you to include single or double quotes without needing to escape them; they are highly recommended for complex strings or multi-line content [6][5]. - Backtick-quoted strings (`...`): Primarily used for paths or arguments that may contain spaces or glob patterns, as backticks interpret asterisks (*) and other characters as globs rather than literals [4][5]. - Bare words: Strings without quotes (e.g., hello) are interpreted as commands or arguments if they contain only \"word\" characters, but they cannot be used in all positions (such as command positions if reserved) [6][5]. Interpolation Interpolated strings ($'...' or $\"...\") allow you to embed variables or expressions directly into strings. Single-quoted interpolation is literal (except for the interpolation itself), while double-quoted interpolation supports C-style escape sequences [6][10].\n\nCitations:\n\n- 1: https://www.nushell.sh/book/running_externals.html\n- 2: https://www.nushell.sh/book/configuration\n- 3: https://www.nushell.sh/commands/docs/run-external.html\n- 4: https://www.nushell.sh/book/moving_around.html\n- 5: https://github.com/nushell/nushell/blob/90d82b7c/crates/nu-mcp/src/instructions.md\n- 6: https://www.nushell.sh/book/working_with_strings.html\n- 7: https://www.nushell.sh/book/thinking_in_nu.html\n- 8: https://github.com/nushell/nushell/issues/13354\n- 9: http://www.nushell.sh/book/pipelines.html\n- 10: https://www.nushell.sh/lang-guide/chapters/strings_and_text.html\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- targeted doctor/report symbols ---'\nrg -n '^pub fn |^fn |format!|println!|writeln!' src/cmd/doctor.rs | sed -n '1,220p'\nprintf '%s\\n' '--- focused report sections ---'\nsed -n '560,760p' src/cmd/doctor.rs\nsed -n '1000,1110p' src/cmd/doctor.rs\nprintf '%s\\n' '--- path discovery symbols ---'\nrg -n '^pub fn |^fn |OFF_PATH|off_path|known' src/nu/paths.rs | sed -n '1,180p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 15300\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\npython3 - <<'PY'\nimport shlex\n\ndef current(path):\n return f\"numan setup nu use {path}\"\n\ncases = [\n r\"C:\\Program Files\\Nu\\nu.exe\",\n \"/tmp/nu; touch /tmp/unintended\",\n \"/tmp/nu$(id)\",\n \"/tmp/nu`id`\",\n \"/tmp/user's nu\",\n]\nfor path in cases:\n command = current(path)\n print(f\"path={path!r}\")\n print(f\"command={command!r}\")\n print(f\"posix_tokens={shlex.split(command, posix=True)!r}\")\n print(f\"contains_shell_operator={any(c in path for c in ';|&<>$`()\\\\n')}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 408\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\npython3 - <<'PY'\nimport shlex\n\ndef current(path):\n return f\"numan setup nu use {path}\"\n\noperator_chars = set(\";|&<>$()`\\n\")\ncases = [\n r\"C:\\Program Files\\Nu\\nu.exe\",\n \"/tmp/nu; touch /tmp/unintended\",\n \"/tmp/nu$(id)\",\n \"/tmp/nu`id`\",\n \"/tmp/user's nu\",\n]\nfor path in cases:\n command = current(path)\n print(f\"path={path!r}\")\n print(f\"command={command!r}\")\n print(f\"posix_tokens={shlex.split(command, posix=True)!r}\")\n print(f\"contains_shell_operator={any(c in operator_chars for c in path)}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 1417\n\n---\n\n\n\n
\n\n**Quote the path in the generated command.**\n\n`Path::display()` leaves spaces and shell metacharacters unescaped. A copied command can split the path or execute unintended syntax. Use shell-safe quoting and add tests for spaces, quotes, backslashes, and metacharacters.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/util/hints.rs` around lines 35 - 37, Update setup_nu_use_existing to\nshell-quote the path before interpolating it into the generated command,\npreserving the existing command structure while safely handling spaces, quotes,\nbackslashes, and shell metacharacters. Add tests covering each of those path\ncases and verify the generated command is safe to copy and execute.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsv","isResolved":false,"isOutdated":false,"path":"tests/setup_nu_test.rs","line":244,"originalLine":244,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_\n\n**Use the constructor for consistency with the sibling test.**\n\nThis test builds `NuSetupArgs` by listing all 8 fields manually. The sibling test at Line 269 builds the analogous invalid state with `NuSetupArgs::install(...)` and then overrides one field. Use the same pattern here for consistency and less duplication.\n\n\n\n
\n♻️ Proposed refactor\n\n```diff\n- let args = NuSetupArgs {\n- action: Some(numan_cli::cmd::setup::NuAction::Use { path: existing }),\n- version: None,\n- force: false,\n- skip_path: true,\n- yes: true,\n- remove: false,\n- use_path: false,\n- use_existing: None,\n- };\n+ let mut args = NuSetupArgs::use_existing(existing, true);\n+ args.skip_path = true;\n```\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\n let mut args = NuSetupArgs::use_existing(existing, true);\n args.skip_path = true;\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@tests/setup_nu_test.rs` around lines 235 - 244, Update the test’s NuSetupArgs\nconstruction to use the existing NuSetupArgs::install(...) constructor, then\noverride the fields needed for the NuAction::Use case, matching the sibling\ntest’s pattern and preserving the current test state.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoU","isResolved":false,"isOutdated":false,"path":"src/cmd/snapshot.rs","line":243,"originalLine":243,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: Piped/CI snapshot delete and rollback now proceed without `--yes`, so destructive operations no longer require an explicit noninteractive opt-in. Preserve this command's prior non-TTY rejection before using the shared prompt helper.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/snapshot.rs, line 243:\n\nPiped/CI snapshot delete and rollback now proceed without `--yes`, so destructive operations no longer require an explicit noninteractive opt-in. Preserve this command's prior non-TTY rejection before using the shared prompt helper.\n\n\n@@ -241,17 +240,7 @@ fn rollback(root: &Path, id: &str, yes: bool) -> Result<()> {\n- bail!(\"Cancelled.\");\n- }\n- Ok(())\n+ crate::util::confirm::confirm_or_bail(prompt, false, \"Cancelled.\")\n }\n \n\n```\n\n
\n\n```suggestion\n if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {\n anyhow::bail!(\"Interactive confirmation required for non-TTY sessions. Pass --yes to proceed without prompting.\");\n }\n crate::util::confirm::confirm_or_bail(prompt, false, \"Cancelled.\")\n```"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoY","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":231,"originalLine":231,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: `setup nu use ` deletes every managed Nu version before validating or confirming the replacement. An invalid path or declining the later `Proceed?` prompt leaves the user with no managed Nu; validate and obtain confirmation before removing the managed directory.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/setup.rs, line 231:\n\n`setup nu use ` deletes every managed Nu version before validating or confirming the replacement. An invalid path or declining the later `Proceed?` prompt leaves the user with no managed Nu; validate and obtain confirmation before removing the managed directory.\n\n\n@@ -78,27 +159,146 @@ pub fn execute_nu(args: &NuSetupArgs, root: &Path) -> Result<()> {\n+ }\n }\n \n+ remove_managed_nu_if_present(root)?;\n let options = NuSetupOptions {\n- yes: args.yes,\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLob","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":32,"originalLine":32,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: A path-like version can select and persist an executable outside the managed version directory. Require a valid SemVer version before passing it to the path-building helpers.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/use_cmd.rs, line 29:\n\nA path-like version can select and persist an executable outside the managed version directory. Require a valid SemVer version before passing it to the path-building helpers.\n\n\n@@ -0,0 +1,199 @@\n+ match args.version.as_str() {\n+ \"list\" => execute_list(root),\n+ \"latest\" => execute_latest(root),\n+ version => execute_switch(root, version),\n+ }\n+}\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLof","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":47,"originalLine":47,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: Installing another version with `--yes` is skipped whenever any active version exists. Check the requested version's `version_binary` path (or bypass the existing-install short circuit when it differs) so side-by-side installs actually run.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/nu/bootstrap.rs, line 47:\n\nInstalling another version with `--yes` is skipped whenever any active version exists. Check the requested version's `version_binary` path (or bypass the existing-install short circuit when it differs) so side-by-side installs actually run.\n\n\n@@ -39,7 +38,16 @@ pub fn managed_nu_dir(root: &Path) -> PathBuf {\n+/// version marker and returns the versioned binary path.\n pub fn managed_nu_binary(root: &Path) -> PathBuf {\n+ // Try to read the active version; if set, return the versioned binary path.\n+ if let Ok(Some(binary)) = crate::nu::version_manager::active_nu_binary(root) {\n+ return binary;\n+ }\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoh","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":43,"originalLine":43,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP2: Concurrent `numan use`/`setup nu` operations can interleave active-version updates with install or migration, leaving the marker pointed at a version whose move/copy is not complete. Serialize this state mutation with the root mutation lock, consistent with other managed-Nu mutations.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/nu/version_manager.rs, line 41:\n\nConcurrent `numan use`/`setup nu` operations can interleave active-version updates with install or migration, leaving the marker pointed at a version whose move/copy is not complete. Serialize this state mutation with the root mutation lock, consistent with other managed-Nu mutations.\n\n\n@@ -0,0 +1,317 @@\n+}\n+\n+/// Write the active Nu version to the marker file.\n+pub fn write_active_version(root: &Path, version: &str) -> Result<()> {\n+ let path = active_version_path(root);\n+ // Ensure parent directory exists.\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLok","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":23,"originalLine":23,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP2: Concurrent `numan setup nu` and `numan use` can race over the active marker or legacy binary, leaving the last writer to decide activation. Acquire the root mutation lock before migration or any switch operation.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/use_cmd.rs, line 21:\n\nConcurrent `numan setup nu` and `numan use` can race over the active marker or legacy binary, leaving the last writer to decide activation. Acquire the root mutation lock before migration or any switch operation.\n\n\n@@ -0,0 +1,199 @@\n+ pub version: String,\n+}\n+\n+pub fn execute(args: &UseArgs, root: &Path) -> Result<()> {\n+ // Attempt migration of legacy single-binary install before any operation.\n+ // This is a no-op if migration has already occurred or no legacy install exists.\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLom","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":240,"originalLine":240,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP2: A failed binary validation still switches the active marker to that invalid binary. Validate `dest` before writing the marker, so failed installs retain the previously active Nu version.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/nu/bootstrap.rs, line 240:\n\nA failed binary validation still switches the active marker to that invalid binary. Validate `dest` before writing the marker, so failed installs retain the previously active Nu version.\n\n\n@@ -225,7 +234,10 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) ->\n+ // Also write a VERSION file in the version directory for reference.\n std::fs::write(dest_dir.join(\"VERSION\"), version.as_bytes())?;\n+ // Set as the active version.\n+ crate::nu::version_manager::write_active_version(root, version)?;\n Ok(dest)\n }\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoo","isResolved":false,"isOutdated":false,"path":"docs/plans/consolidated-multi-repo-roadmap.md","line":327,"originalLine":327,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP3: The roadmap section updated in this PR describes `numan use` as a post-1.0 reserved stub whose `` form \"auto-installs if missing\", with an active marker at `/tools/nushell/active` and PATH/shim management. The same PR actually implements a fully working `numan use |latest|list` command that does NOT auto-download (it errors with a hint to run `setup nu --version`) and stores its active marker at `/nu_state/active-version.json` with no shim. Since this roadmap is the single cross-repo plan others (plugin backfill waves, activation-keying, registry work) rely on, the stale \"reserved stub\" claim and the auto-install behavior contradict the shipped design and can mislead downstream planning. Recommend updating the roadmap to describe the implemented behavior (set active marker only, no auto-install, active-version.json location) and removing the \"exists as a stub\" line.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At docs/plans/consolidated-multi-repo-roadmap.md, line 327:\n\nThe roadmap section updated in this PR describes `numan use` as a post-1.0 reserved stub whose `` form \"auto-installs if missing\", with an active marker at `/tools/nushell/active` and PATH/shim management. The same PR actually implements a fully working `numan use |latest|list` command that does NOT auto-download (it errors with a hint to run `setup nu --version`) and stores its active marker at `/nu_state/active-version.json` with no shim. Since this roadmap is the single cross-repo plan others (plugin backfill waves, activation-keying, registry work) rely on, the stale \"reserved stub\" claim and the auto-install behavior contradict the shipped design and can mislead downstream planning. Recommend updating the roadmap to describe the implemented behavior (set active marker only, no auto-install, active-version.json location) and removing the \"exists as a stub\" line.\n\n\n@@ -287,9 +285,58 @@ Ship 1.0 when **all** of the following are true:\n+Source: awesome-nu + manual discovery. Entries marked `NEEDS_RESEARCH` need\n+their version history filled in before promotion.\n+\n+**Command reserved:** `numan use` exists as a stub in 0.1.x (prints a\n+\"planned post-1.0\" message) so the UX shape is locked.\n+\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLor","isResolved":false,"isOutdated":false,"path":"src/cli.rs","line":64,"originalLine":64,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP3: The user-facing help text for `numan use` says it is \"reserved, post-1.0\", but the command is fully implemented and exposed as a working subcommand (`use `, `use latest`, `use list`). This contradicts the actual behavior users see in `numan --help` and could make them think the feature is unavailable. Recommend dropping the \"(reserved, post-1.0)\" suffix or rewording the doc comment so the generated help matches the implemented functionality.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cli.rs, line 64:\n\nThe user-facing help text for `numan use` says it is \"reserved, post-1.0\", but the command is fully implemented and exposed as a working subcommand (`use `, `use latest`, `use list`). This contradicts the actual behavior users see in `numan --help` and could make them think the feature is unavailable. Recommend dropping the \"(reserved, post-1.0)\" suffix or rewording the doc comment so the generated help matches the implemented functionality.\n\n\n@@ -61,4 +61,6 @@ pub enum Commands {\n Setup(cmd::setup::SetupCommands),\n /// Install and activate a starter package that fits your Nu\n Try(cmd::try_cmd::TryArgs),\n+ /// Switch the active managed Nu version (reserved, post-1.0)\n+ Use(cmd::use_cmd::UseArgs),\n }\n\n```\n\n
\n\n```suggestion\n /// Switch the active managed Nu version\n```"}]}}]}}}}} diff --git a/src/cli.rs b/src/cli.rs index 5482d245..6fe7179f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,6 +61,6 @@ pub enum Commands { Setup(cmd::setup::SetupCommands), /// Install and activate a starter package that fits your Nu Try(cmd::try_cmd::TryArgs), - /// Switch the active managed Nu version (reserved, post-1.0) + /// Switch the active managed Nu version Use(cmd::use_cmd::UseArgs), } diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index ad61cc55..999e6c9d 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -240,6 +240,16 @@ fn execute_use_path(yes: bool, root: &Path) -> Result<()> { } fn execute_use_existing(path: &Path, yes: bool, root: &Path) -> Result<()> { + // Validate the replacement binary before removing the managed install. + let resolved = path + .canonicalize() + .with_context(|| format!("Failed to resolve Nushell binary '{}'", path.display()))?; + if !resolved.is_file() { + bail!( + "'{}' is not an executable file. Pass the path to an existing nu binary.", + path.display() + ); + } remove_managed_nu_if_present(root)?; let options = NuSetupOptions { yes, diff --git a/src/cmd/use_cmd.rs b/src/cmd/use_cmd.rs index 462c40fb..e0ea9989 100644 --- a/src/cmd/use_cmd.rs +++ b/src/cmd/use_cmd.rs @@ -7,7 +7,6 @@ use anyhow::{bail, Context, Result}; -use crate::util::hints; use clap::Args; use std::path::Path; @@ -76,7 +75,7 @@ fn execute_latest(root: &Path) -> Result<()> { fn execute_switch(root: &Path, version: &str) -> Result<()> { let version = version_manager::normalize_version(version)?; // Validate the version is installed. - if !version_manager::is_version_installed(root, version) { + if !version_manager::is_version_installed(root, &version) { let installed = version_manager::list_installed_versions(root)?; let hint = if installed.is_empty() { format!( @@ -98,7 +97,7 @@ fn execute_switch(root: &Path, version: &str) -> Result<()> { } // Switch to the requested version. - version_manager::write_active_version(root, version) + version_manager::write_active_version(root, &version) .with_context(|| format!("Failed to switch to Nu {}", version))?; println!("Switched to Nu {}.", version); Ok(()) diff --git a/src/nu/version_manager.rs b/src/nu/version_manager.rs index d2b81b10..56ef6e3f 100644 --- a/src/nu/version_manager.rs +++ b/src/nu/version_manager.rs @@ -65,7 +65,11 @@ pub fn versioned_nu_dir(root: &Path) -> PathBuf { /// Parse and normalize a Nu version, rejecting path-like values. pub fn normalize_version(version: &str) -> Result { let version = version.strip_prefix('v').unwrap_or(version); - if version.is_empty() || version.contains('/') || version.contains('\\\\') || version.contains("..") { + if version.is_empty() + || version.contains('/') + || version.contains('\\') + || version.contains("..") + { bail!("Invalid Nu version '{}'; expected X.Y.Z", version) } let parsed = semver::Version::parse(version) From 62b86565e15c4438f44ac0b82c659afa49c3fe69 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sat, 1 Aug 2026 09:39:12 -0700 Subject: [PATCH 10/61] chore: remove temp files --- .tmp_parse.py | 10 ---------- .tmp_threads.json | 1 - 2 files changed, 11 deletions(-) delete mode 100644 .tmp_parse.py delete mode 100644 .tmp_threads.json diff --git a/.tmp_parse.py b/.tmp_parse.py deleted file mode 100644 index ca9a5bd3..00000000 --- a/.tmp_parse.py +++ /dev/null @@ -1,10 +0,0 @@ -import json, sys -sys.stdout.reconfigure(encoding='utf-8') -data = json.load(open('.tmp_threads.json', encoding='utf-8')) -threads = data['data']['repository']['pullRequest']['reviewThreads']['nodes'] -unresolved = [t for t in threads if not t['isResolved']] -print(f"Total unresolved: {len(unresolved)}\n") -for t in unresolved: - c = t['comments']['nodes'][0] - body_short = c['body'][:120].replace('\n', ' ') - print(f"{t['id']} outdated={t['isOutdated']} {t['path']}:{t.get('line','?')} [{c['author']['login']}] {body_short}") diff --git a/.tmp_threads.json b/.tmp_threads.json deleted file mode 100644 index 198615e9..00000000 --- a/.tmp_threads.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"PRRT_kwDOTGvfHs6VpHsH","isResolved":false,"isOutdated":true,"path":"src/cmd/setup.rs","line":300,"originalLine":300,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"This message is printed whenever the managed Nu directory is removed, but it hard-codes “replaced by --use-existing”. The same helper is also used by `setup nu path`, so the message is misleading."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsW","isResolved":false,"isOutdated":true,"path":"src/cmd/use_cmd.rs","line":null,"originalLine":24,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"`migrate_legacy_install()` returns a `Result`, but the error is currently ignored. If the legacy `nu` exists but `--version` fails or rename fails, `numan use` will silently proceed and may later fail in less obvious ways. It’s better to propagate the migration error (or at least surface it) before continuing."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsZ","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":44,"originalLine":41,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"User-facing guidance still references the old `setup nu --version ` flag, but `setup nu` is now positional (`setup nu `).\n\nThis issue also appears in the following locations of the same file:\n- line 64\n- line 77"}]}},{"id":"PRRT_kwDOTGvfHs6VpHsj","isResolved":false,"isOutdated":true,"path":"src/nu/version_manager.rs","line":39,"originalLine":37,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"The docstring says this returns `None` when the marker file is invalid, but a JSON parse error currently returns `Err(...)`. Either the docstring should be tightened, or the implementation should treat parse failures as “no active version” (so callers can fall back safely)."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsp","isResolved":false,"isOutdated":true,"path":"src/cli.rs","line":65,"originalLine":65,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"The `Use` command is now implemented in this PR, but the CLI help text still says it’s “reserved, post-1.0”, which is misleading."}]}},{"id":"PRRT_kwDOTGvfHs6VpHsu","isResolved":false,"isOutdated":true,"path":"AGENTS.md","line":68,"originalLine":68,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"AGENTS.md describes `use_cmd.rs` as a “reserved stub”, but this PR adds a working `numan use` implementation. Updating this keeps the project structure doc accurate."}]}},{"id":"PRRT_kwDOTGvfHs6VpHs5","isResolved":false,"isOutdated":true,"path":"src/nu/bootstrap.rs","line":686,"originalLine":686,"comments":{"nodes":[{"author":{"login":"copilot-pull-request-reviewer"},"body":"In `execute_nu_setup_with_installer`, a second `numan setup nu` run in non-TTY (scripts/CI) will now auto-confirm the reinstall prompt and proceed to re-download/reinstall without an explicit `--force`, which is not idempotent and differs from the prior non-TTY behavior. Also, with versioned installs `prepend_process_path(managed_nu_dir(root))` no longer prepends a directory that actually contains `nu` (the binary lives under `/tools/nushell//`). Consider treating non-TTY like the no-op path (ensure PATH/persist if needed, then return) unless `--force` is set, and prepend the binary’s parent directory instead of `/tools/nushell`."}]}},{"id":"PRRT_kwDOTGvfHs6VpHzm","isResolved":true,"isOutdated":true,"path":"src/nu/version_manager.rs","line":null,"originalLine":207,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n1\\. legacy_binary.parent().unwrap() may panic 📘 Rule violation ☼ Reliability\n\n
\nmigrate_legacy_install() uses unwrap() on legacy_binary.parent(), which can panic and crash\ncallers if the path ever lacks a parent. Library code must return errors instead of panicking.\n
\n\n\n
\nAgent Prompt\n\n```\n## Issue description\n`src/nu/version_manager.rs` uses `legacy_binary.parent().unwrap()` in non-test code, which can panic.\n\n## Issue Context\nCompliance requires library code to avoid panic triggers like `unwrap()`/`expect()` and return errors instead.\n\n## Fix Focus Areas\n- src/nu/version_manager.rs[206-208]\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHzr","isResolved":false,"isOutdated":true,"path":"src/cmd/setup.rs","line":239,"originalLine":239,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n3\\. setup nu path persists path 📘 Rule violation ≡ Correctness\n\n
\nThe new setup nu path/setup nu use flows call register_existing_nu(), which persists PATH\nchanges (e.g., PowerShell user PATH update on Windows), writing outside $NUMAN_ROOT. Install-time\ncode must not modify locations outside $NUMAN_ROOT.\n
\n\n\n
\nAgent Prompt\n\n```\n## Issue description\n`setup nu path`/`setup nu use` invoke `register_existing_nu()`, which persists PATH updates (outside `$NUMAN_ROOT`).\n\n## Issue Context\nCompliance requires install-time code paths to avoid writes/modifications outside `$NUMAN_ROOT` (e.g., user PATH, home dir, system directories).\n\n## Fix Focus Areas\n- src/cmd/setup.rs[231-251]\n- src/nu/bootstrap.rs[441-472]\n- src/nu/bootstrap.rs[496-512]\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHzw","isResolved":true,"isOutdated":false,"path":"src/nu/version_manager.rs","line":85,"originalLine":71,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Action\n\n4\\. Version path traversal risk 🐞 Bug ⛨ Security\n\n
\nVersion strings are used directly as path components for install directories and active-binary\nresolution, so values like "../…" or absolute paths can escape $NUMAN_ROOT/tools/nushell/ and\nlater cause Numan to reference or execute an unintended binary. This breaks the managed-root\nboundary and can overwrite files outside the root during installation.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\nNu version identifiers are currently used directly as filesystem path components (e.g., `/tools/nushell//nu`). Without validation, a crafted version containing path separators, `..`, or an absolute path can escape the intended directory and later be treated as the active Nu binary.\n\n### Issue Context\n- `version_install_dir()` joins `version` into the managed directory.\n- `install_from_archive()` uses `version_install_dir()` / `version_binary()` to create directories and copy binaries.\n- `numan use ` can write arbitrary strings into the active marker, and downstream code resolves that into a binary path.\n\n### Fix Focus Areas\n- src/nu/version_manager.rs[57-124]\n- src/nu/bootstrap.rs[197-241]\n- src/cmd/use_cmd.rs[72-100]\n\n### Implementation guidance\n- Introduce a strict parser/validator for Nu versions (recommended: accept `vX.Y.Z` and normalize to `X.Y.Z`; otherwise error).\n- Reject any version containing path separators (`/` or `\\\\`), `..`, or absolute paths.\n- After building the install path, defensively ensure it is still under `versioned_nu_dir(root)` (e.g., via canonicalization when possible).\n- Ensure `write_active_version()` only persists normalized/validated versions, and `numan use` rejects invalid versions early.\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHzz","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":28,"originalLine":25,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n5\\. Migration errors are dropped 🐞 Bug ☼ Reliability\n\n
\nnuman use runs legacy-install migration but discards any error, so failures (e.g., legacy `nu\n--version` execution or parse failures) are silently ignored and users can get misleading ΓÇ£No Nu\nversions installedΓÇ¥ outcomes. This loses critical diagnostics during upgrade from the legacy layout.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\n`use_cmd::execute()` ignores the `Result` from `migrate_legacy_install()`, hiding real failures in migration and causing confusing follow-on behavior.\n\n### Issue Context\n`migrate_legacy_install()` can fail when:\n- executing the legacy binary,\n- parsing its version output,\n- creating the target directory,\n- renaming the binary.\n\n### Fix Focus Areas\n- src/cmd/use_cmd.rs[21-30]\n- src/nu/version_manager.rs[132-210]\n\n### Implementation guidance\n- Replace `let _ = migrate_legacy_install(root);` with `migrate_legacy_install(root).with_context(...)?;`.\n- If you truly want best-effort migration, log a warning to stderr on error and continue, but do not silently discard it.\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHz1","isResolved":true,"isOutdated":false,"path":"src/nu/version_manager.rs","line":39,"originalLine":37,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Remediation\n\n6\\. Marker invalid handling mismatch 🐞 Bug ☼ Reliability\n\n
\nread_active_version() claims it returns None when the marker is invalid, but it actually returns\nan error on JSON parse failure, which can break numan use list/latest if the marker is corrupted.\nThis is a contract mismatch between documentation and behavior.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\n`read_active_version()` documents ΓÇ£Returns None if marker is invalidΓÇ¥ but propagates JSON parse errors instead.\n\n### Issue Context\nThis file is user-local state (`/nu_state/active-version.json`) and can be corrupted; list/switch commands should either degrade gracefully or document that corruption is fatal.\n\n### Fix Focus Areas\n- src/nu/version_manager.rs[25-38]\n\n### Implementation guidance\nChoose one:\n- If invalid should be non-fatal (matches current docs): catch `serde_json::from_str` errors and return `Ok(None)` (optionally `eprintln!` a warning).\n- If invalid should be fatal: update the doc comment and ensure callers surface a clear remediation message (e.g., delete the marker / rerun setup).\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpHz2","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":44,"originalLine":41,"comments":{"nodes":[{"author":{"login":"qodo-code-review"},"body":"\"Informational\"\n\n7\\. Use guidance uses deprecated flag 🐞 Bug ≡ Correctness\n\n
\nnuman use still tells users to run numan setup nu --version ..., but this PR redesigns setup to\naccept a positional version (numan setup nu <VERSION>). These messages appear on the common\nempty-install and version-not-installed paths, so users will be instructed to run a non-existent\nflag.\n
\n\n\n
\nAgent Prompt\n\n```\n### Issue description\n`use_cmd` emits outdated setup guidance using `--version`, which no longer matches the new `setup nu` CLI.\n\n### Issue Context\nThe PR changes setup syntax (see `hints::setup_nu_version()`), but `use_cmd` hard-codes old strings.\n\n### Fix Focus Areas\n- src/cmd/use_cmd.rs[38-41]\n- src/cmd/use_cmd.rs[64-68]\n- src/cmd/use_cmd.rs[77-93]\n- src/util/hints.rs[21-27]\n\n### Implementation guidance\n- Replace hard-coded `setup nu --version ...` strings with `hints::setup_nu_version(version)` (or update to `numan setup nu `).\n- Ensure the empty-install and not-installed branches print the correct new syntax.\n```\n\nΓôÿ Copy this prompt and use it to remediate the issue with your preferred AI generation tools\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpH0_","isResolved":false,"isOutdated":true,"path":"src/cmd/setup.rs","line":243,"originalLine":243,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Validate the replacement before deleting managed Nu**\n\nWhen managed versions exist, `numan setup nu use ` deletes the entire managed directory here before `register_existing_nu` canonicalizes or validates the replacement. An interactive user who declines the later PATH prompt also loses every managed version even though the command reports cancellation. Fully validate, confirm, and register the replacement before removing the managed installation.\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH1A","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":102,"originalLine":98,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Refresh cached Nu identity when switching versions**\n\nOn an initialized root, writing only `active-version.json` leaves `nu_state/paths.json` pointing to the old executable and version. Commands such as activate/deactivate load that cached `NuPaths`, while search/install compatibility also prefers its cached version, so `numan use ` prints success but Numan continues resolving packages and invoking the previous Nu until the user separately runs `numan init --refresh`. The switch must update/re-probe the cached identity or perform the refresh as part of the command.\n\nAGENTS.md reference: [AGENTS.md:L141-L141](https://github.com/tonythethompson/numan/blob/8062a11ebc4bd26546e99b000e5f9cb0981235e7/AGENTS.md#L141-L141)\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH1B","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":227,"originalLine":227,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Repoint the managed symlink for a new version**\n\nOn Unix after a normal first setup, `~/.local/bin/nu` points to that version-specific binary. Installing another version now creates a different target, but the subsequent `persist_user_path_unix` call rejects an existing symlink whose target differs, so `setup nu --force`—including the managed-pin offer—downloads the release and marks it active, then exits with an error. A symlink that points inside Numan's managed directory needs to be atomically repointed when the active version changes.\n\nAGENTS.md reference: [AGENTS.md:L232-L232](https://github.com/tonythethompson/numan/blob/8062a11ebc4bd26546e99b000e5f9cb0981235e7/AGENTS.md#L232-L232)\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH1D","isResolved":false,"isOutdated":true,"path":"src/cmd/use_cmd.rs","line":null,"originalLine":24,"comments":{"nodes":[{"author":{"login":"chatgpt-codex-connector"},"body":"**![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) Serialize numan use mutations**\n\nThis entry point can move the legacy binary, create version directories, and rewrite `active-version.json`, but it never acquires the root mutation lock. If `numan use` or even `numan use list` runs concurrently with `setup nu`, the migration and active-marker writes race the locked installer, allowing either command to silently overwrite the other's selected version. Acquire `acquire_mutation_lock(root)` around migration and switching while keeping a truly read-only list path separate.\n\nAGENTS.md reference: [AGENTS.md:L139-L139](https://github.com/tonythethompson/numan/blob/8062a11ebc4bd26546e99b000e5f9cb0981235e7/AGENTS.md#L139-L139)\n\nUseful? React with 👍 / 👎."}]}},{"id":"PRRT_kwDOTGvfHs6VpH6k","isResolved":true,"isOutdated":true,"path":"src/cmd/use_cmd.rs","line":null,"originalLine":40,"comments":{"nodes":[{"author":{"login":"greptile-apps"},"body":"\"P1\" **Stale `--version` flag in help text**\n\nThree locations in this new file still reference `--version`, the deprecated flag this PR removes. `execute_list` (line 40), `execute_latest` (line 66), and `execute_switch` (lines 80–81, 87) all tell users to run `numan setup nu --version ` — but the correct new invocation is `numan setup nu `. Users copying these error hints will get a deprecation warning on top of the problem they're already debugging.\n\n
Prompt To Fix With AI\n\n`````markdown\nThis is a comment left during a code review.\nPath: src/cmd/use_cmd.rs\nLine: 40\n\nComment:\n**Stale `--version` flag in help text**\n\nThree locations in this new file still reference `--version`, the deprecated flag this PR removes. `execute_list` (line 40), `execute_latest` (line 66), and `execute_switch` (lines 80ΓÇô81, 87) all tell users to run `numan setup nu --version ` ΓÇö but the correct new invocation is `numan setup nu `. Users copying these error hints will get a deprecation warning on top of the problem they're already debugging.\n\n---\n\nFor each issue above, determine whether it is valid and should be fixed. If so, fix it directly.\n`````\n
\n\n\"Fix"}]}},{"id":"PRRT_kwDOTGvfHs6VpH6n","isResolved":true,"isOutdated":true,"path":"src/cmd/setup.rs","line":300,"originalLine":300,"comments":{"nodes":[{"author":{"login":"greptile-apps"},"body":"\"P2\" The printed message still references `--use-existing`, the deprecated flag removed in this PR. It should describe the new subcommand that invoked the deletion.\n\n```suggestion\n println!(\n \"Removed managed Nushell at '{}' (replaced by external binary).\",\n managed_dir.display()\n );\n```\n\n
Prompt To Fix With AI\n\n`````markdown\nThis is a comment left during a code review.\nPath: src/cmd/setup.rs\nLine: 297-300\n\nComment:\nThe printed message still references `--use-existing`, the deprecated flag removed in this PR. It should describe the new subcommand that invoked the deletion.\n\n```suggestion\n println!(\n \"Removed managed Nushell at '{}' (replaced by external binary).\",\n managed_dir.display()\n );\n```\n\n---\n\nFor each issue above, determine whether it is valid and should be fixed. If so, fix it directly.\n`````\n
\n\nNote: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!\n\n\"Fix"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrb","isResolved":false,"isOutdated":false,"path":"CLAUDE.md","line":36,"originalLine":36,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nif command -v serena >/dev/null 2>&1; then\n serena tools list\nelif command -v uv >/dev/null 2>&1; then\n uv run serena tools list\nelse\n echo \"Serena is not installed; verify the pinned tool names manually.\" >&2\n exit 1\nfi\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 223\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- CLAUDE.md lines 1-50 ---'\nsed -n '1,50p' CLAUDE.md\n\nprintf '%s\\n' '--- Serena-related tracked files and references ---'\ngit ls-files | rg -i '(^|/)(agents\\.md|claude\\.md|serena|pyproject\\.toml|uv\\.lock|requirements.*|package\\.json|mcp|config)' || true\nrg -n -i 'serena|find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol|jet_brains_move|_find_implementations|_insert_after_symbol|_safe_delete|rename\\b' . \\\n -g '!node_modules' -g '!dist' -g '!build' || true\n\nprintf '%s\\n' '--- relevant dependency/config excerpts ---'\nfor f in pyproject.toml uv.lock requirements.txt requirements-dev.txt package.json .mcp.json; do\n if [ -f \"$f\" ]; then\n echo \"### $f\"\n rg -n -i -C 3 'serena|oraios|mcp' \"$f\" || true\n fi\ndone\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 5756\n\n---\n\n🌐 Web query:\n\n`Find the authoritative Serena documentation for the current tool names and the versioned tool list, specifically `find_implementations`, `insert_after_symbol`, `rename_symbol`, `safe_delete_symbol`, and `jet_brains_move`.`\n\n💡 Result:\n\nThe authoritative documentation for Serena tools is maintained by Oraios AI and can be accessed via their official documentation portal [1][2]. Serena is a toolkit that provides semantic code tools for coding agents using the Model Context Protocol (MCP). It operates using either a Language Server Protocol (LSP) backend or, when configured, a JetBrains-specific backend [3][4]. Tool Definitions and Status: - find_implementations: Finds symbols that implement a given symbol using the language server backend [5][1]. - insert_after_symbol: Inserts content after the end of the definition of a given symbol [5][6]. - rename_symbol: Renames a symbol throughout the codebase, leveraging language server refactoring capabilities [5][7]. - safe_delete_symbol: Deletes a symbol, reporting the reference count if it is blocked [8]. - jet_brains_move: Documentation indicates that Serena uses specific JetBrains-prefixed tools for JetBrains-backed operations (e.g., jet_brains_find_implementations) [5][9]. There is no official \"jet_brains_move\" tool listed in the standard Serena documentation; refactoring operations like moving symbols are typically handled through the IDE backend's native capabilities when integrated [3][4]. You can view the complete, up-to-date tool list by running the command ` tools list --all` [10]. For the most recent technical references, refer to the official Serena Tools documentation page [1][11].\n\nCitations:\n\n- 1: https://oraios.github.io/serena/01-about/035_tools.html\n- 2: https://oraios.github.io/serena/01-about/000_intro.html\n- 3: https://graphify.net/mcpservers/serena/\n- 4: https://deepwiki.com/oraios/serena/7.3-tool-reference\n- 5: https://oraios.github.io/serena/_sources/01-about/035_tools.md\n- 6: https://github.com/oraios/serena/blob/0c915bd1/src/serena/tools/symbol_tools.py\n- 7: https://mcp.directory/blog/serena-mcp-complete-guide-2026\n- 8: https://github.com/postfix/serena\n- 9: https://github.com/oraios/serena/blob/981f560f/src/serena/tools/jetbrains_tools.py\n- 10: https://github.com/oraios/serena/blob/9cd33aa5/docs/01-about/035_tools.md\n- 11: https://github.com/oraios/serena/blob/main/README.md\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -euo pipefail\n\nbase='https://raw.githubusercontent.com/oraios/serena/main'\n\nprintf '%s\\n' '--- official tool documentation: asserted names ---'\ncurl -fsSL \"$base/docs/01-about/035_tools.md\" |\n rg -n -C 2 'find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol|jet_brains_move|move' || true\n\nprintf '%s\\n' '--- official source: symbol tools ---'\ncurl -fsSL \"$base/src/serena/tools/symbol_tools.py\" |\n rg -n 'def (find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol)|find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol' || true\n\nprintf '%s\\n' '--- official source: JetBrains tools ---'\ncurl -fsSL \"$base/src/serena/tools/jetbrains_tools.py\" |\n rg -n -C 2 'def .*move|jet_brains_move|move' || true\n\nprintf '%s\\n' '--- official repository refs for available tags ---'\ncurl -fsSL 'https://api.github.com/repos/oraios/serena/tags?per_page=10' |\n jq -r '.[] | [.name, .commit.sha] | `@tsv`' || true\n```\n\nLength of output: 2113\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- all tracked dependency/config candidates ---'\ngit ls-files | rg -i '(^|/)(Cargo\\.toml|Cargo\\.lock|pyproject\\.toml|uv\\.lock|requirements[^/]*|package(-lock)?\\.json|.*mcp.*|.*serena.*|.*config.*)$' || true\n\nprintf '%s\\n' '--- later CLAUDE.md workflow references ---'\nsed -n '50,75p' CLAUDE.md\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 1507\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -euo pipefail\n\nfor ref in main 0c915bd1 981f560f 9cd33aa5; do\n echo \"--- symbol_tools.py ref=$ref ---\"\n curl -fsSL \"https://raw.githubusercontent.com/oraios/serena/$ref/src/serena/tools/symbol_tools.py\" |\n sed -n '560,730p' |\n rg -n -C 5 'def |tool|find_implementations|insert_after_symbol|rename_symbol|safe_delete_symbol' || true\n\n echo \"--- jetbrains_tools.py ref=$ref ---\"\n curl -fsSL \"https://raw.githubusercontent.com/oraios/serena/$ref/src/serena/tools/jetbrains_tools.py\" |\n sed -n '100,245p' |\n rg -n -C 8 'def |tool|move|delete' || true\ndone\n```\n\nLength of output: 27259\n\n---\n\n🌐 Web query:\n\n`site:github.com/oraios/serena \"find_implementations\" \"safe_delete_symbol\" \"jet_brains_move\"`\n\n💡 Result:\n\nThe `safe_delete_symbol` and `jet_brains_move` tools are implementations found within the Serena project, which interfaces with a JetBrains IDE plugin to perform refactoring tasks [1][2][3]. The `safe_delete_symbol` tool is a wrapper that utilizes the `safeDelete` endpoint provided by the JetBrains plugin client [1][2]. It is designed to safely delete a symbol, file, or directory after checking for existing usages [1]. It supports parameters such as `name_path`, `relative_path`, `delete_even_if_used`, and `propagate` to control the deletion process [1][2]. The `jet_brains_move` tool is implemented to handle moving symbols to different locations (such as a new file or parent symbol) using the IDE's internal refactoring engine [3]. Similar to the delete functionality, it is exposed through the JetBrains plugin client, requiring source identifiers (like `name_path` or `relative_path`) and target identifiers (like `target_parent_name_path` or `target_relative_path`) to perform the operation [3]. These tools are configured and enabled within the project's internal mode configuration files (e.g., `jetbrains.yml`) and are utilized by the Serena agent to automate complex code refactoring tasks safely, reducing the need for manual, error-prone operations [4][3][5].\n\nCitations:\n\n- 1: https://github.com/oraios/serena/blob/a0984015/src/serena/tools/jetbrains_tools.py\n- 2: https://github.com/oraios/serena/blob/a0984015/src/serena/jetbrains/jetbrains_plugin_client.py\n- 3: https://github.com/oraios/serena/pull/1223\n- 4: https://github.com/oraios/serena/blob/901bd215/src/serena/resources/config/internal_modes/jetbrains.yml\n- 5: https://github.com/oraios/serena/issues/792\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -euo pipefail\n\nfor ref in main a0984015 0c915bd1; do\n echo \"--- $ref: implementation lookup and tool registration ---\"\n for file in src/serena/tools/symbol_tools.py src/serena/tools/__init__.py src/serena/agent.py src/serena/tools/tool.py; do\n url=\"https://raw.githubusercontent.com/oraios/serena/$ref/$file\"\n body=\"$(curl -fsSL \"$url\" 2>/dev/null || true)\"\n if [ -n \"$body\" ]; then\n echo \"### $file\"\n printf '%s\\n' \"$body\" |\n rg -n -C 4 'FindImplementations|find_implementations|SafeDeleteSymbol|RenameSymbolTool|InsertAfterSymbolTool|class .*Tool|snake|tool_name|name =' || true\n fi\n done\ndone\n```\n\nLength of output: 50343\n\n---\n\n\n\n
\n\n**Use valid, version-pinned Serena tool names.**\n\nReplace the invalid names with `find_implementations`, `insert_after_symbol`, `rename_symbol`, and `safe_delete_symbol`. List `jet_brains_move` only as an optional JetBrains-backend tool. Add a Serena version pin because this repository has none.\n\n
\n🧰 Tools\n\n
\n🪛 markdownlint-cli2 (0.23.1)\n\n[warning] 34-34: Spaces inside emphasis markers\n\n(MD037, no-space-in-emphasis)\n\n
\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@CLAUDE.md` around lines 22 - 36, Update the tool mapping in CLAUDE.md to\nreplace invalid Serena names with find_implementations, insert_after_symbol,\nrename_symbol, and safe_delete_symbol. List jet_brains_move only as an optional\nJetBrains-backend tool, and add the required Serena version pin using the\nrepository’s documented versioning convention.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrf","isResolved":false,"isOutdated":false,"path":"CLAUDE.md","line":124,"originalLine":124,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n**Do not force a model-specific co-author trailer.**\n\nThese lines require every commit created under these instructions to carry a fixed identity and model version. That metadata can become stale or create unauthorized provenance. Add attribution only when the user explicitly requests the exact trailer.\n\n
\nProposed correction\n\n```diff\n-For commit messages, use a HEREDOC to preserve formatting. End the trailer with:\n-Co-Authored-By: Claude Opus 4.7 (1M context) \n+For commit messages, use a HEREDOC to preserve formatting. Add co-author attribution only when the user explicitly requests it.\n```\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@CLAUDE.md` around lines 123 - 124, Update the commit-message guidance in\nCLAUDE.md to remove the mandatory model-specific Co-Authored-By trailer and\nrequire that exact attribution only when the user explicitly requests it; retain\nthe HEREDOC formatting guidance.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrh","isResolved":false,"isOutdated":false,"path":"docs/numan-doctor.md","line":81,"originalLine":81,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Validate the replacement before deleting managed Nu.**\n\n`doctor --fix` maps `nu.binary.found_off_path` to `numan setup nu use ` at all three references. The current setup flow removes the managed Nu directory before it validates and registers ``. A stale or invalid path can therefore destroy a working managed installation.\n\nValidate the candidate and protect state before replacement. Remove old files only after registration succeeds. Fix this in `src/cmd/setup.rs`; changing the hint text is not sufficient.\n\nAs per coding guidelines, mutating paths must protect state before writes.\n\n\n\n\n\n\n\n\nAlso applies to: 118-118, 266-266\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@docs/numan-doctor.md` around lines 80 - 81, Update the setup flow in\nsrc/cmd/setup.rs for the nu.binary.found_off_path path so the candidate \nis validated and successfully registered before removing the managed Nu\ndirectory. Preserve the existing managed installation until registration\ncompletes, and only delete or replace old files after that operation succeeds;\napply this consistently to every setup-nu replacement path.\n```\n\n
\n\n\n\n\n\n\n\n_Sources: Coding guidelines, MCP tools_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJri","isResolved":false,"isOutdated":false,"path":"docs/plans/consolidated-multi-repo-roadmap.md","line":328,"originalLine":328,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n**\"Stub\" description contradicts the implementation shipped in this PR.**\n\nThis line says `numan use` is a stub that prints a \"planned post-1.0\" message. In this same PR, `src/cmd/use_cmd.rs` implements real version switching, latest-selection, and listing, and persists `active-version.json`. Update this line so the roadmap doesn't claim the command is not ready when it is.\n\nThis shares a root cause with the stale doc comment on `Commands::Use` in `src/cli.rs`; see the consolidated comment.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@docs/plans/consolidated-multi-repo-roadmap.md` around lines 327 - 328, Update\nthe `**Command reserved:**` roadmap entry to describe `numan use` as implemented\nand available, including version switching, latest-version selection, listing,\nand active-version persistence, rather than calling it a stub or claiming it is\nplanned post-1.0.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrj","isResolved":true,"isOutdated":false,"path":"README.md","line":193,"originalLine":193,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nrg -n -C 8 'active-version|acquire_mutation_lock|create_snapshot|NuPaths|init.*refresh|activate' \\\n src/cmd/use_cmd.rs src/nu src/state tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50380\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- command and state files ---'\nfd -t f . src | rg '(^|/)(use|activate|init|lock|snapshot|version|paths).*\\.rs$|command|cli'\nprintf '%s\\n' '--- use references ---'\nrg -n -C 6 '\\buse\\b|active-version|ActiveVersion|NuPaths::load|validate_drift|acquire_mutation_lock|create_snapshot' src tests README.md \\\n | head -n 1200\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50379\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path\n\nfor p in sorted(Path(\"src\").rglob(\"*.rs\")):\n text = p.read_text(errors=\"replace\")\n if \"active-version\" in text or \"ActiveVersion\" in text or \"acquire_mutation_lock\" in text or \"create_snapshot\" in text:\n print(f\"\\n--- {p} ---\")\n lines = text.splitlines()\n for i, line in enumerate(lines, 1):\n if any(x in line for x in (\n \"active-version\", \"ActiveVersion\", \"acquire_mutation_lock\",\n \"create_snapshot\", \"NuPaths::load\", \"validate_drift\",\n \"SnapshotTrigger\",\n )):\n lo, hi = max(1, i - 12), min(len(lines), i + 18)\n print(f\"[{lo}-{hi}]\")\n for n in range(lo, hi + 1):\n print(f\"{n}: {lines[n-1]}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- src/cmd/use_cmd.rs ---'\ncat -n src/cmd/use_cmd.rs\n\nprintf '%s\\n' '--- CLI command wiring ---'\ncat -n src/cli.rs\nrg -n -C 12 'Use|use_cmd|SetupNu|setup nu|active_version|ActiveVersion|set_active|activate' src/cmd src/cli.rs\n\nprintf '%s\\n' '--- version manager ---'\ncat -n src/nu/version_manager.rs | sed -n '1,260p'\n\nprintf '%s\\n' '--- init refresh write path ---'\ncat -n src/cmd/init.rs | sed -n '159,240p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path\n\nneedles = (\n \"active_version\", \"ActiveVersion\", \"active-version\",\n \"NuPaths::save\", \".save(root)\", \"acquire_mutation_lock\",\n \"create_snapshot\", \"SnapshotTrigger\",\n)\n\nfor p in [Path(\"src/cmd/use_cmd.rs\"), Path(\"src/cmd/setup.rs\"),\n Path(\"src/cmd/init.rs\"), Path(\"src/nu/version_manager.rs\"),\n Path(\"src/cli.rs\")]:\n if not p.exists():\n continue\n lines = p.read_text(errors=\"replace\").splitlines()\n print(f\"\\n--- {p} ({len(lines)} lines) ---\")\n hits = [i for i, line in enumerate(lines) if any(n in line for n in needles)]\n shown = set()\n for i in hits:\n lo, hi = max(0, i - 15), min(len(lines), i + 16)\n if any(n in shown for n in range(lo, hi)):\n continue\n shown.update(range(lo, hi))\n for n in range(lo, hi):\n print(f\"{n+1}: {lines[n]}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 19687\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- use command ---'\nsed -n '1,105p' src/cmd/use_cmd.rs\n\nprintf '%s\\n' '--- CLI wiring ---'\ncat src/cli.rs\n\nprintf '%s\\n' '--- setup command variants and path selection ---'\nsed -n '23,140p' src/cmd/setup.rs\nsed -n '148,225p' src/cmd/setup.rs\nrg -n -C 8 'active_nu_binary|find_nu_executable_with_root|version_binary|version_install_dir|write_active_version|NuPaths::detect|NuPaths::load' src\n\nprintf '%s\\n' '--- exact version-manager path functions ---'\nsed -n '35,115p' src/nu/version_manager.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path\nimport re\n\nfiles = [\n Path(\"src/cmd/use_cmd.rs\"),\n Path(\"src/nu/version_manager.rs\"),\n Path(\"src/nu/paths.rs\"),\n Path(\"src/cli.rs\"),\n]\n\nfor path in files:\n text = path.read_text()\n print(f\"\\n--- {path} ---\")\n for name in (\n \"execute\", \"execute_switch\", \"write_active_version\",\n \"version_install_dir\", \"version_binary\",\n \"find_nu_executable_with_root\",\n ):\n matches = list(re.finditer(rf\"(?m)^(?:pub\\s+)?(?:fn|async fn)\\s+{name}\\b\", text))\n for m in matches:\n start = text.rfind(\"\\n\", 0, m.start()) + 1\n # Print the function through the next top-level item or 140 lines.\n lines = text[start:].splitlines()\n depth = 0\n out = []\n for line in lines[:160]:\n out.append(line)\n depth += line.count(\"{\") - line.count(\"}\")\n if out and depth <= 0 and \"{\" in \"\\n\".join(out):\n break\n print(\"\\n\".join(out))\nPY\n\nprintf '%s\\n' '--- mutation/snapshot calls in use path ---'\nrg -n 'acquire_mutation_lock|create_snapshot|SnapshotTrigger|NuPaths|lockfile|activation' \\\n src/cmd/use_cmd.rs src/nu/version_manager.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 3446\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\npython3 - <<'PY'\nfrom pathlib import Path, PurePosixPath\n\n# Read-only behavioral probe for the path construction used by the source:\n# versioned_nu_dir(root).join(version), with no validation step visible in the\n# implementation. This does not execute repository code.\nroot = PurePosixPath(\"/numan\")\nbase = root / \"tools\" / \"nushell\"\nfor version in [\"0.113.1\", \"../outside\", \"../../outside\", \"/absolute\", \"0.113.1/../../outside\"]:\n print(f\"{version!r} -> {(base / version).as_posix()}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 398\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\nprintf '%s\\n' '--- refresh state reconciliation ---'\nsed -n '186,305p' src/cmd/init.rs\n\nprintf '%s\\n' '--- active-version snapshot coverage ---'\nsed -n '32,205p' src/state/snapshot.rs\nrg -n 'active.version|active-version|active_version|nu_version|nu_executable' \\\n src/state/snapshot.rs src/state/rollback.rs src/cmd/init.rs\n\nprintf '%s\\n' '--- source-level mutation-boundary verifier ---'\npython3 - <<'PY'\nfrom pathlib import Path\n\nuse = Path(\"src/cmd/use_cmd.rs\").read_text()\nactivate = Path(\"src/cmd/activate.rs\").read_text()\nrefresh = Path(\"src/cmd/init.rs\").read_text()\n\ndef body(text, name):\n marker = f\"fn {name}\"\n start = text.index(marker)\n brace = text.index(\"{\", start)\n depth = 0\n for end in range(brace, len(text)):\n if text[end] == \"{\":\n depth += 1\n elif text[end] == \"}\":\n depth -= 1\n if depth == 0:\n return text[start:end + 1]\n raise AssertionError(name)\n\nuse_execute = body(use, \"pub fn execute\")\nuse_switch = body(use, \"fn execute_switch\")\nactivate_execute = body(activate, \"fn execute_with_registrar_and_runner\")\nrefresh_execute = body(refresh, \"fn execute_refresh\")\n\nprint(\"use.execute acquires lock:\", \"acquire_mutation_lock\" in use_execute)\nprint(\"use.execute creates snapshot:\", \"create_snapshot\" in use_execute)\nprint(\"use.execute writes active marker:\", \"write_active_version\" in use_execute)\nprint(\"use.execute refreshes NuPaths:\", \"NuPaths\" in use_execute)\nprint(\"use.execute reconciles activation state:\",\n \"refresh_activation_records\" in use_execute or \"AutoloadState\" in use_execute)\nprint(\"activate loads cached NuPaths before drift validation:\",\n \"NuPaths::load(root)\" in activate_execute and\n activate_execute.index(\"NuPaths::load(root)\") <\n activate_execute.index(\"validate_drift\"))\nprint(\"refresh writes NuPaths:\", \"new_paths.save(root)\" in refresh_execute)\nprint(\"refresh updates activation records:\", \"refresh_activation_records\" in refresh_execute)\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 13450\n\n---\n\n\n\n
\n\n**Make `numan use` a complete, locked state transition.**\n\n`src/cmd/use_cmd.rs` writes `active-version.json` without the mutation lock or a pre-mutation snapshot. It also leaves `NuPaths`, `lockfile.nu_version`, activation records, and autoload state unchanged. `activate` then loads and validates the previous cached Nu executable.\n\nUpdate all dependent state and reconcile registrations before returning. If `numan init --refresh` remains required, block activation until refresh and reactivation complete. Do not treat metadata-only updates as registration reconciliation.\n\nReject version values that escape `/tools/nushell`; raw path joins currently allow values such as `../outside`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@README.md` at line 193, Update the numan use command to perform a complete\nlocked state transition: acquire the mutation lock, snapshot state before\nchanges, and validate the requested version remains under /tools/nushell.\nReconcile NuPaths, lockfile.nu_version, activation records, and autoload\nregistrations before returning instead of only writing active-version.json.\nEnsure activate does not use the previous cached Nu executable; if numan init\n--refresh is required, block activation until refresh and reactivation finish,\nand do not treat metadata-only changes as registration reconciliation.\n```\n\n
\n\n\n\n\n\n\n\n_Sources: Coding guidelines, MCP tools_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrl","isResolved":true,"isOutdated":false,"path":"README.md","line":326,"originalLine":326,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nrg -n -C 6 'tools/nushell|active-version|join\\(|Version::parse|semver' \\\n src/nu src/cmd tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- candidate files ---'\nfd -t f '(^|/)(setup|use|nu|version|tools|paths)' src tests 2>/dev/null | head -200 || true\nprintf '%s\\n' '--- command and path references ---'\nrg -n -S -g '*.rs' \\\n 'enum .*Command|Setup|Use|setup nu|use |Nushell|nushell|tools|active_version|active-version|parse_version|Version::parse|NuVersion' \\\n src tests --glob '!src/cmd/activate.rs' --glob '!src/cmd/doctor.rs' --glob '!tests/nupm_compat_test.rs' \\\n | head -500\nprintf '%s\\n' '--- README command context ---'\nsed -n '300,340p' README.md\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 37848\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- setup.rs ---'\ncat -n src/cmd/setup.rs | sed -n '20,260p'\nprintf '%s\\n' '--- use_cmd.rs ---'\ncat -n src/cmd/use_cmd.rs | sed -n '1,230p'\nprintf '%s\\n' '--- version_manager.rs ---'\ncat -n src/nu/version_manager.rs | sed -n '1,330p'\nprintf '%s\\n' '--- bootstrap.rs relevant ranges ---'\ncat -n src/nu/bootstrap.rs | sed -n '1,260p'\ncat -n src/nu/bootstrap.rs | sed -n '620,720p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 44580\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- install_version and installer paths ---'\ncat -n src/nu/bootstrap.rs | sed -n '270,380p'\nprintf '%s\\n' '--- active binary consumers ---'\nrg -n -C 5 'managed_nu_binary|active_nu_binary|version_binary|version_install_dir|nu_executable' \\\n src --glob '*.rs' \\\n | head -400\nprintf '%s\\n' '--- existing validation and tests ---'\nrg -n -C 4 'version.*(valid|invalid|parse|travers)|travers.*version|install_from_archive|execute_nu_setup|is_version_installed|write_active_version' \\\n src tests --glob '*.rs' \\\n | head -500\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' '--- managed binary call sites ---'\nrg -n -C 4 'managed_nu_binary\\(|active_nu_binary\\(|find_nu_executable_with_root\\(' \\\n src --glob '*.rs'\nprintf '%s\\n' '--- exact release-tag flow ---'\ncat -n src/nu/bootstrap.rs | sed -n '99,120p'\ncat -n src/nu/bootstrap.rs | sed -n '280,320p'\nprintf '%s\\n' '--- standalone path and URL probe ---'\npython3 - <<'PY'\nfrom pathlib import PurePosixPath\nfrom urllib.parse import urljoin\n\nroot = PurePosixPath(\"/tmp/numan-root\")\nbase = root / \"tools\" / \"nushell\"\n\ndef rust_join(base, child):\n # Path::join replaces the base when the child is absolute.\n child_path = PurePosixPath(child)\n return child_path if child_path.is_absolute() else base / child_path\n\nfor value in [\"0.113.1\", \"../../outside\", \"../../../tmp/nu\", \"/tmp/nu\"]:\n binary = rust_join(base, value) / \"nu\"\n print(f\"{value!r} -> {binary}\")\n\nrelease_api = \"https://api.github.com/repos/nushell/nushell/releases/tags/\"\nfor value in [\"0.113.1\", \"../../outside\", \"/tmp/nu\"]:\n print(f\"tag input {value!r} -> {urljoin(release_api, value)}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 10577\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\npython3 - <<'PY'\nimport os\nimport tempfile\n\nwith tempfile.TemporaryDirectory() as tmp:\n root = os.path.join(tmp, \"root\")\n os.makedirs(os.path.join(root, \"tools\", \"nushell\"))\n outside = os.path.join(root, \"outside\", \"nu\")\n os.makedirs(os.path.dirname(outside))\n open(outside, \"wb\").close()\n\n version = \"../../outside\"\n candidate = os.path.join(root, \"tools\", \"nushell\", version, \"nu\")\n print(\"candidate:\", candidate)\n print(\"normalized:\", os.path.realpath(candidate))\n print(\"exists:\", os.path.isfile(candidate))\n print(\"escapes_root:\", not os.path.realpath(candidate).startswith(\n os.path.realpath(root) + os.sep\n ))\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 301\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\npython3 - <<'PY'\nimport os\nimport tempfile\n\nwith tempfile.TemporaryDirectory() as tmp:\n root = os.path.join(tmp, \"root\")\n os.makedirs(os.path.join(root, \"tools\", \"nushell\"))\n outside = os.path.join(tmp, \"outside\", \"nu\")\n os.makedirs(os.path.dirname(outside))\n open(outside, \"wb\").close()\n\n for version in (\"../../outside\", \"../../../outside\"):\n candidate = os.path.join(root, \"tools\", \"nushell\", version, \"nu\")\n resolved = os.path.realpath(candidate)\n print(version, \"->\", resolved, \"exists:\", os.path.isfile(candidate),\n \"escapes_root:\", os.path.commonpath([root, resolved]) != root)\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 323\n\n---\n\n\n\n
\n\n**Validate version arguments before URL and path handling.**\n\n`numan use ` passes raw input to `version_install_dir`; `../../../outside` can make `is_version_installed` select a binary outside `$NUMAN_ROOT`. The active marker then causes `managed_nu_binary` to return that path. Validate version values before path construction. Validate `setup nu [VERSION]` before interpolating the value into the release-tag URL. Accept only normalized semver values, excluding `latest` and `list` where applicable. Add traversal tests.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@README.md` at line 326, Validate version arguments at the command boundary\nbefore any path construction or release-tag URL interpolation, including the\nnuman use flow around version_install_dir and the setup nu [VERSION] flow.\nAccept only normalized semver values, excluding latest and list where\napplicable, and reject traversal or other invalid input; ensure invalid values\ncannot influence is_version_installed or managed_nu_binary paths. Add tests\ncovering traversal attempts and the relevant setup/use validation behavior.\n```\n\n
\n\n\n\n\n\n\n\n_Sources: Coding guidelines, MCP tools_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrm","isResolved":false,"isOutdated":false,"path":"README.md","line":329,"originalLine":329,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Align documentation with the implemented `numan use` contract.**\n\nThe PR implements version selection, `latest`, and `list`, but the documentation set still presents the command as deferred and omits it from the user-facing reference and release notes.\n\n- `README.md#L326-L329`: add `numan use `, `numan use latest`, and `numan use list`.\n- `AGENTS.md#L68-L68`: replace the reserved-stub description with the current behavior.\n- `CHANGELOG.md#L10-L24`: add `numan use` and active-version tracking to the Unreleased notes.\n\n
\n📍 Affects 3 files\n\n- `README.md#L326-L329` (this comment)\n- `AGENTS.md#L68-L68`\n- `CHANGELOG.md#L10-L24`\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@README.md` around lines 326 - 329, Update the documentation for the\nimplemented numan use contract: in README.md lines 326-329, add entries for\nnuman use , numan use latest, and numan use list; in AGENTS.md line 68,\nreplace the reserved-stub description with the current behavior; and in\nCHANGELOG.md lines 10-24, add Unreleased notes covering numan use and\nactive-version tracking.\n```\n\n
\n\n\n\n\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJro","isResolved":false,"isOutdated":false,"path":"src/cli.rs","line":65,"originalLine":65,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n**Doc comment says \"reserved\" but the command is fully implemented.**\n\nThe doc comment reads \"(reserved, post-1.0)\", but `Commands::Use` dispatches to `use_cmd::execute`, which switches versions, lists installs, and persists `active-version.json`. This is not a reserved stub. Update the doc comment to describe the actual behavior. Do not tell users a working command is not ready — that misleads them.\n\nThis shares a root cause with the stub description in `docs/plans/consolidated-multi-repo-roadmap.md`; see the consolidated comment.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cli.rs` around lines 64 - 65, Update the doc comment for the\nCommands::Use variant to describe its implemented behavior—switching the active\nmanaged Nu version—instead of labeling it reserved or post-1.0. Keep the command\nwiring unchanged.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrq","isResolved":false,"isOutdated":false,"path":"src/cmd/activate.rs","line":1695,"originalLine":1695,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Three tests for the new confirmation behaviour skip themselves when a TTY is present.** The shared root cause is that `confirm_or_auto` reads the global `std::io::stdin()` terminal state directly. No test can control that state, so each test wraps its assertions in an `is_terminal` guard and reports success without asserting anything on a developer machine. The behaviour these tests exist to protect is then verified only in CI, and only by accident of the environment. Inject the TTY state into the confirmation helper, then assert both branches unconditionally.\n- `src/cmd/activate.rs#L1686-L1695`: drop the `is_terminal` guard and assert the non-TTY policy against an injected terminal state instead of the process stdin.\n- `src/cmd/deactivate.rs#L1599-L1605`: drop the `is_terminal` guard and assert the same way; this test also has no doc comment explaining the guard, unlike its counterpart in `src/cmd/activate.rs`.\n- `tests/activate_test.rs#L612-L621`: keep the end-to-end activation assertion, but remove the early return so the test either runs or is marked `#[ignore]` rather than passing vacuously.\n\n\n\n\n\nBased on coding guidelines: \"Unit tests must use injectable seams such as `FakeCandidateRunner` and registrars\" and \"Behavior changes require corresponding tests, including relevant failure paths.\"\n\n
\n📍 Affects 3 files\n\n- `src/cmd/activate.rs#L1686-L1695` (this comment)\n- `src/cmd/deactivate.rs#L1599-L1605`\n- `tests/activate_test.rs#L612-L621`\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/activate.rs` around lines 1686 - 1695, Make the confirmation helper’s\nTTY state injectable instead of reading global std::io::stdin() directly, while\npreserving both auto-confirm and interactive behaviors. In src/cmd/activate.rs\nlines 1686-1695, remove the is_terminal guard and assert the non-TTY policy\nusing the injected state; make the equivalent unconditional assertion in\nsrc/cmd/deactivate.rs lines 1599-1605. In tests/activate_test.rs lines 612-621,\nremove the early return so the end-to-end assertion always executes, or mark the\ntest #[ignore] if it cannot run reliably.\n```\n\n
\n\n\n\n\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrr","isResolved":true,"isOutdated":false,"path":"src/cmd/setup.rs","line":39,"originalLine":39,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Find every user-facing string that suggests `setup nu --version`.\nrg -n --type=rust -C2 'setup nu' \necho '--- docs/markdown ---'\nrg -n 'setup nu' --glob '*.md'\necho '--- confirm version arg definition ---'\nast-grep run --pattern 'pub version: Option' --lang rust src/cmd/setup.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 355\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- setup definitions ---'\nsed -n '1,180p' src/cmd/setup.rs\n\necho '--- use hints ---'\nsed -n '1,150p' src/cmd/use_cmd.rs\n\necho '--- all setup nu references ---'\nrg -n -C2 'setup[[:space:]]+nu|--version' . --glob '!target/**' --glob '!Cargo.lock'\n\necho '--- CLI tests and command wiring ---'\nrg -n -C3 'Setup|setup|NuAction|version: Option|setup nu' src tests 2>/dev/null || true\n\necho '--- clap dependency and project test setup ---'\nrg -n -C2 '^clap|clap::|try_parse|parse_from|CommandFactory' Cargo.toml Cargo.lock src tests 2>/dev/null || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50380\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\npython3 - <<'PY'\nfrom pathlib import Path\nfor p in [Path(\"src/cmd/setup.rs\"), Path(\"src/cmd/use_cmd.rs\")]:\n print(f\"--- {p} ---\")\n for i, line in enumerate(p.read_text().splitlines(), 1):\n if p.name == \"setup.rs\" and 1 <= i <= 120:\n print(f\"{i:4}: {line}\")\n elif p.name == \"use_cmd.rs\" and 60 <= i <= 115:\n print(f\"{i:4}: {line}\")\nPY\n\necho '--- tracked files relevant to CLI behavior ---'\ngit ls-files | rg '(^|/)(main|cli|command|setup|use|test).*|Cargo.toml$' | head -100\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 8994\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- parser construction and existing parse assertions ---'\nrg -n -C5 'parse_from|try_parse_from|Cli::|setup.*nu|NuAction' tests src --glob '*.rs' | head -300\n\necho '--- CLI and Cargo configuration ---'\nsed -n '1,130p' src/cli.rs\nsed -n '1,120p' Cargo.toml\nrg -n 'name = \"clap\"|version = ' Cargo.lock | head -30\n\necho '--- remaining exact stale hints, excluding historical planning docs ---'\nrg -n -C1 --glob '*.rs' --glob '*.md' --glob '!docs/plans/**' --glob '!CHANGELOG.md' 'setup nu --version'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 21557\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\npython3 - <<'PY'\n# Check whether the hinted version values can equal the NuAction subcommand names.\n# This is a data-shape check, not a claim about clap's parser implementation.\nimport re\nnames = {\"remove\", \"path\", \"use\"}\nexamples = [\"0.113.1\", \"0.113.1-alpha.1\", \"latest\", \"remove\", \"path\", \"use\"]\nfor value in examples:\n semver_like = bool(re.fullmatch(r\"\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?\", value))\n print(f\"{value!r}: semver_like={semver_like}, collides={value in names}\")\nPY\n```\n\nLength of output: 389\n\n---\n\n🌐 Web query:\n\n`Clap Rust derive optional positional argument alongside optional subcommand parsing behavior`\n\n💡 Result:\n\nIn `clap` (Rust), combining optional positional arguments and optional subcommands often creates ambiguity because the parser must decide whether a given token is a value for the positional argument or the name of a subcommand [1][2]. ### Key Parsing Behavior 1. Subcommand Precedence: By default, `clap` often prioritizes subcommands over positional arguments when parsing [3]. If an input matches a registered subcommand name, `clap` will attempt to parse it as a subcommand regardless of whether a preceding positional argument is still expecting values [1][2]. 2. Positional Ambiguity: If your CLI design allows positional arguments to appear before subcommands, the parser may \"consume\" tokens meant for a subcommand as values for the positional argument, or vice versa, leading to unexpected errors or incorrect matches [4][3][5]. ### Recommended Approaches To manage this behavior, consider the following strategies based on your CLI requirements: * Use `subcommand_precedence_over_arg`: If you want the subcommand to be preferred even when a positional argument could technically accept the input, you can set `#[command(subcommand_precedence_over_arg = true)]` on your main struct [4][5]. * Structure Subcommands Properly: The most robust way to handle shared context is to move the shared arguments into the subcommand structs themselves [4]. If global access is required, define the argument with `global = true` so it is available to the parent and all child subcommands [6][7]. * Avoid Unbounded Positionals: Mixing unbounded positional arguments (e.g., `Vec`) with subcommands is a common source of bugs [4]. If you must use them, consider requiring the `--` separator to explicitly terminate positional arguments before providing a subcommand [1][5]. * Use `Option`: As with any optional argument or subcommand in `clap`'s `derive` API, wrapping the field in `Option` is the standard way to denote that the element is not required [8]. ### Troubleshooting * If the CLI fails to parse correctly, check if your positional argument name matches a subcommand name [3]. This is a frequent cause of \"unexpected argument\" errors [3]. * Utilize `arg_required_else_help` or `subcommand_required` if you need to enforce a specific structure, but be aware these settings can change how `clap` reacts to partial input [9][4][5]. For complex cases where the ambiguity cannot be resolved by standard settings, consider refactoring the CLI design to avoid overlapping name spaces between positional arguments and subcommand identifiers [4][6].\n\nCitations:\n\n- 1: https://docs.rs/clap/latest/src/clap/_concepts.rs.html\n- 2: https://docs.rs/clap/latest/i686-pc-windows-msvc/clap/_concepts/index.html\n- 3: https://github.com/clap-rs/clap/issues/5513\n- 4: https://github.com/clap-rs/clap/issues/3721\n- 5: https://github.com/clap-rs/clap/issues/4238\n- 6: https://github.com/clap-rs/clap/issues/5690\n- 7: https://github.com/clap-rs/clap/discussions/2260\n- 8: https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html\n- 9: https://docs.rs/clap/latest/clap/_tutorial/index.html\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- complete CLI parse tests ---'\nsed -n '150,230p' tests/setup_nu_test.rs\nsed -n '250,265p' tests/setup_nu_test.rs\n\necho '--- setup execution dispatch ---'\nsed -n '150,215p' src/cmd/setup.rs\n\necho '--- version-hint call sites and user-facing text ---'\nrg -n -C1 --glob '*.rs' --glob '*.md' --glob '!docs/plans/**' --glob '!CHANGELOG.md' \\\n 'setup_nu_version|setup nu --version|setup nu |setup nu \\[VERSION\\]|setup nu [0-9]'\n\necho '--- all source references to the affected use messages ---'\nrg -n -C2 'No Nu versions installed|is not installed' src tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 5524\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"/tmp/nonexistent\") # keep this verifier read-only and independent of the repository runtime\nprint(\"A semver version such as 0.113.1 is distinct from NuAction names: remove, path, use.\")\nprint(\"The repository's parser tests cover each NuAction name and reject an action plus a version.\")\nPY\n```\n\nLength of output: 302\n\n---\n\n\n\n
\n\n**Use the positional syntax in `numan use` hints.**\n\n`NuSetupArgs::version` is positional. Replace each `numan setup nu --version {}` hint in `src/cmd/use_cmd.rs` with `numan setup nu {}`. `numan setup nu 0.113.1` parses as an install, while `remove`, `path`, and `use` remain subcommands.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 33 - 39, Update every setup hint in the use\ncommand implementation to pass the Nushell version positionally, replacing the\n--version form with numan setup nu {} while preserving the existing version\nplaceholder and subcommand behavior.\n```\n\n
\n\n\n\n\n\n\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrv","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":207,"originalLine":207,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**`execute_nu_impl` mutates the root without the mutation lock when doctor calls it.**\n\n`execute_nu` acquires the lock at Line 156 and then delegates here. The doctor repair path does not. `src/cmd/doctor.rs` drops its lock at Line 1065 with `drop(lock.take())`, then calls `setup::execute_nu_impl` at Line 1079 and Line 1119.\n\n`execute_nu_impl` now dispatches to mutating actions that were not previously reachable this way:\n\n- `remove_managed_nu` → `std::fs::remove_dir_all` on the managed Nushell directory.\n- `execute_use_path` and `execute_use_existing` → `remove_managed_nu_if_present`, plus PATH persistence.\n- the default branch → `bootstrap::execute_nu_setup`, which writes `nu_state/active-version.json`.\n\nA concurrent `numan install` or `numan activate` can therefore run while doctor deletes the managed Nu binary those commands depend on.\n\nAcquire the lock inside `execute_nu_impl`, or give doctor a locked entry point.\n\n\n\n\n\nBased on coding guidelines: \"Every new mutating code path must acquire the mutation lock and snapshot the lockfile before writes.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 161 - 207, Ensure execute_nu_impl acquires the\nmutation lock and snapshots the lockfile before dispatching any mutating action,\nincluding remove_managed_nu, execute_use_path, execute_use_existing, and\nbootstrap::execute_nu_setup. Preserve the existing execute_nu lock behavior\nwithout double-locking by introducing a clearly separated locked entry point or\nadjusting the caller contract, and update the doctor repair path to use the\nappropriate entry point.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJrx","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":191,"originalLine":191,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**The `--skip-path` conflict check is duplicated verbatim.**\n\nLines 173-178 and Lines 186-191 contain the identical bail message for the deprecated flag path and the subcommand path. Two copies of one invariant drift apart.\n\nExtract a small helper and call it from both branches.\n\n\n\n\n
\n♻️ Proposed refactor\n\n```diff\n+fn reject_skip_path_for_use(skip_path: bool) -> Result<()> {\n+ if skip_path {\n+ bail!(\n+ \"numan setup nu use cannot be combined with --skip-path. \\\n+ Off-PATH registration must persist the binary directory to PATH.\"\n+ );\n+ }\n+ Ok(())\n+}\n```\n\nThen replace both inline blocks with `reject_skip_path_for_use(args.skip_path)?;`.\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\nfn reject_skip_path_for_use(skip_path: bool) -> Result<()> {\n if skip_path {\n bail!(\n \"numan setup nu use cannot be combined with --skip-path. \\\n Off-PATH registration must persist the binary directory to PATH.\"\n );\n }\n Ok(())\n}\n\n if args.skip_path {\n reject_skip_path_for_use(args.skip_path)?;\n }\n return execute_use_existing(existing, args.yes, root);\n }\n\n match &args.action {\n Some(NuAction::Remove) => remove_managed_nu(root, args.yes),\n Some(NuAction::Path) => execute_use_path(args.yes, root),\n Some(NuAction::Use { path }) => {\n reject_skip_path_for_use(args.skip_path)?;\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 173 - 191, Extract the duplicated --skip-path\nvalidation into a small helper such as reject_skip_path_for_use, preserving the\nexisting bail message and error behavior. Replace both inline checks in the\ndeprecated flag path and the NuAction::Use branch with calls to the helper,\npassing args.skip_path and propagating its result.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJr6","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":252,"originalLine":252,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🔴 Critical_ | _⚡ Quick win_\n\n**`setup nu use ` deletes the working managed install before it validates the replacement.**\n\nLine 243 calls `remove_managed_nu_if_present`, which runs `std::fs::remove_dir_all` on `tools/nushell`. Only afterwards does Line 250 call `bootstrap::register_existing_nu`, and that function rejects the input for three separate reasons (`src/nu/bootstrap.rs` Lines 444-455):\n\n- `canonicalize` fails when the path does not exist.\n- `resolved.is_file()` is false for a directory.\n- `validate_nushell_binary` fails when the file is not a runnable Nushell.\n\nSo `numan setup nu use /typo/nu` destroys a working managed installation and then returns an error. The user is left with no managed Nu and no replacement. `execute_use_path` at Line 231 has the same ordering, and `validate_nushell_binary` can still fail there.\n\nValidate and register first. Remove the managed install only after the replacement is proven good.\n\n\n\n\n
\n🛡️ Proposed fix: validate before deleting\n\n```diff\n fn execute_use_existing(path: &Path, yes: bool, root: &Path) -> Result<()> {\n- remove_managed_nu_if_present(root)?;\n let options = NuSetupOptions {\n yes,\n force: false,\n skip_path: false,\n version: None,\n };\n- bootstrap::register_existing_nu(path, &options)?;\n+ // Validate and register the replacement first. Only then discard the\n+ // managed install, so a bad path cannot destroy a working setup.\n+ bootstrap::register_existing_nu(path, &options)?;\n+ remove_managed_nu_if_present(root)?;\n Ok(())\n }\n```\n\nApply the same reordering in `execute_use_path` at Lines 231-238.\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\nfn execute_use_existing(path: &Path, yes: bool, root: &Path) -> Result<()> {\n let options = NuSetupOptions {\n yes,\n force: false,\n skip_path: false,\n version: None,\n };\n // Validate and register the replacement first. Only then discard the\n // managed install, so a bad path cannot destroy a working setup.\n bootstrap::register_existing_nu(path, &options)?;\n remove_managed_nu_if_present(root)?;\n Ok(())\n}\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 242 - 252, Update execute_use_existing and\nexecute_use_path to call bootstrap::register_existing_nu before\nremove_managed_nu_if_present, so canonicalization, type checks, and\nvalidate_nushell_binary complete before deleting the managed installation.\nPreserve the existing options and error propagation, and remove the managed\ninstall only after registration succeeds.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJr-","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":301,"originalLine":301,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n**This deletes every installed Nu version and leaves `active-version.json` dangling.**\n\n`bootstrap::managed_nu_dir` returns `root/tools/nushell`, and `version_manager::versioned_nu_dir` returns the same path. After this PR that directory is the parent of every side-by-side version. `remove_dir_all` at Line 291 therefore removes all installed versions, not just one legacy binary.\n\nThe function also leaves `nu_state/active-version.json` in place. `version_manager::active_nu_binary` then reads a version whose binary no longer exists, returns `None`, and `bootstrap::managed_nu_binary` silently falls back to the legacy path. The recorded active version and the filesystem disagree, with no error and no warning.\n\n`remove_managed_nu` at Lines 255-285 has the same stale-state problem.\n\nClear the active-version marker whenever the versioned tree is removed. Also state in the message which versions were removed, so the deletion is not silent.\n\nThe message at Line 298 still names the deprecated `--use-existing` flag. Use the `setup nu use` subcommand name.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 288 - 301, Update remove_managed_nu_if_present\nand remove_managed_nu to clear the active-version marker whenever the\nmanaged/versioned Nushell tree is removed, keeping version_manager state\nconsistent with the filesystem. Ensure removal messaging identifies the versions\nremoved rather than implying a single legacy binary deletion, and replace the\ndeprecated --use-existing wording with the setup nu use subcommand name.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsA","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":375,"originalLine":375,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🔵 Trivial_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Does the vendored loader carry the ownership marker?\nfd -t f 'loader.nu' assets\necho '--- OWNERSHIP_MARKER definition ---'\nrg -n 'OWNERSHIP_MARKER' --type=rust\necho '--- first lines of the vendored loader ---'\nfd -t f 'loader.nu' assets --exec head -5 {}\necho '--- assert_managed_file_owned body ---'\nast-grep run --pattern 'pub fn assert_managed_file_owned($$$) { $$$ }' --lang rust src/util/fs_safety.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 227\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\necho '--- relevant files ---'\nfd -t f 'setup.rs|fs_safety.rs|loader.nu' src assets\n\necho '--- setup loader and ownership call sites ---'\nrg -n -C 8 'install_loader_file|assert_managed_file_owned|VENDOR_LOADER|OWNERSHIP_MARKER|confirm_or_bail' src/cmd/setup.rs src/util/fs_safety.rs\n\necho '--- marker definitions and usages ---'\nrg -n -C 4 'OWNERSHIP_MARKER|managed_file|ownership' src assets\n\necho '--- vendored loader prefix ---'\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"assets/nushell-loader/loader.nu\")\ndata = p.read_bytes()\nprint(\"path:\", p)\nprint(\"first 160 bytes:\", repr(data[:160]))\nprint(\"first line:\", repr(data.splitlines()[0] if data.splitlines() else b\"\"))\nPY\n\necho '--- exact ownership helper body ---'\nsed -n '220,280p' src/util/fs_safety.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\n\npython3 - <<'PY'\nfrom pathlib import Path\n\nloader = Path(\"assets/nushell-loader/loader.nu\").read_bytes()\nmarker = b\"# Generated and managed by Numan. Do not edit.\\n# Numan autoload schema: 1\\n\"\nprint(\"loader_starts_with_ownership_marker:\", loader.startswith(marker))\nprint(\"loader_first_line:\", repr(loader.splitlines()[0] if loader.splitlines() else b\"\"))\nPY\n\npython3 - <<'PY'\nfrom pathlib import Path\ns = Path(\"src/cmd/setup.rs\").read_text()\nstart = s.index(\"fn install_loader_file\")\nend = s.index(\"\\n}\\n\", start) + 2\nbody = s[start:end]\nprint(\"ownership_check_after_vendored_compare:\", body.index(\"assert_managed_file_owned\") > body.index(\"if existing == VENDOR_LOADER\"))\nprint(\"ownership_check_before_write:\", body.index(\"assert_managed_file_owned\") < body.index(\"write_bytes_atomic\"))\nprint(\"nested_force_guard_present:\", \"if !args.force\" in body[body.index(\"assert_managed_file_owned\"):])\nPY\n\necho '--- focused setup tests ---'\nsed -n '490,535p' src/cmd/setup.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 1757\n\n---\n\n\n\n
\n\n**Remove the redundant guard and use a loader-specific error label.** `VENDOR_LOADER` already starts with `OWNERSHIP_MARKER`, so the ownership check accepts NumanΓÇÖs loader. If the loader is modified, the helper reports that `numan.nu` drifted. The inner `if !args.force` is also unreachable because the outer block already excludes `args.force`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 364 - 375, In the loader installation flow\naround assert_managed_file_owned, remove the redundant if !args.force guard and\nkeep confirmation directly under the existing non-force branch. Update the\nownership validation to use a loader-specific error label so modified loader\nfiles report loader drift rather than the numan.nu label.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsD","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":649,"originalLine":649,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Add a regression test for the `setup nu use ` ordering bug.**\n\nThese tests cover removal and legacy-flag delegation, and they correctly avoid spawning a real `nu`. The uncovered case is the destructive one: `execute_use_existing` with a path that fails validation must leave the managed install intact.\n\nAdd a test that seeds a managed directory, calls `execute_use_existing` with a nonexistent path, asserts the call returns an error, and asserts the managed directory still exists. That test fails today, which is the point.\n\n\n\n\n\nBased on coding guidelines: \"Behavior changes require corresponding tests, including relevant failure paths.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/setup.rs` around lines 589 - 649, Add a regression test alongside the\nexisting Nu setup tests for execute_use_existing: create a managed installation\nwith a fake nu.exe, invoke it with a nonexistent existing-path value, assert the\ncall returns an error, and verify the managed directory remains intact. Avoid\nspawning a real Nu and preserve the existing removal and delegation test\ncoverage.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsF","isResolved":false,"isOutdated":false,"path":"src/cmd/try_cmd.rs","line":23,"originalLine":23,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Describe the `--yes` safety boundary.**\n\nThe help says `--yes` skips confirmation prompts, but `offer_managed_nu_pin` deliberately rejects `yes` to prevent silent managed-Nu installation or switching. The test `offer_managed_nu_pin_yes_refuses_silent_switch` confirms this behavior.\n\nState that `--yes` does not consent to managed-Nu switching, or expose a separate explicit option.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/try_cmd.rs` at line 23, Update the `--yes` help text near\n`offer_managed_nu_pin` to state that it skips ordinary confirmation prompts but\ndoes not authorize silent managed-Nu installation or switching; preserve the\nexisting refusal behavior verified by\n`offer_managed_nu_pin_yes_refuses_silent_switch`.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsH","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":31,"originalLine":31,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**Acquire the mutation lock before migrating or writing active-version state.**\n\n`execute` is a new mutating code path: `migrate_legacy_install` renames the installed binary on disk, and every branch (`execute_list`, `execute_latest`, `execute_switch`) can write `active-version.json`. No call to `acquire_mutation_lock(root)` guards any of this.\n\nWithout the lock, a concurrent `numan use`, `install`, `update`, or `remove` can race with the binary rename or the active-version write. Acquire the mutation lock at the top of `execute`, matching the pattern required for other mutating commands.\n\n\n\nAs per coding guidelines: \"Every new mutating code path must acquire the mutation lock and snapshot the lockfile before writes.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` around lines 21 - 31, Update execute to acquire the\nmutation lock before calling migrate_legacy_install or dispatching to\nexecute_list, execute_latest, or execute_switch, and snapshot the lockfile\nbefore any of these writes, following the established mutating-command pattern\nand propagating setup errors.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsJ","isResolved":true,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":24,"originalLine":24,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n**Don't discard the migration error silently.**\n\n`let _ = version_manager::migrate_legacy_install(root);` swallows the `Result`. The referenced implementation renames the legacy binary before calling `write_active_version`. If the rename succeeds but the subsequent write fails, the binary has already moved, yet the failure is invisible. The user gets no diagnostic and `numan use` continues as if there was nothing to migrate.\n\nLog or surface the error instead of discarding it.\n\n\n\n\n
\n🛠️ Proposed fix\n\n```diff\n- // Attempt migration of legacy single-binary install before any operation.\n- // This is a no-op if migration has already occurred or no legacy install exists.\n- let _ = version_manager::migrate_legacy_install(root);\n+ // Attempt migration of legacy single-binary install before any operation.\n+ // This is a no-op if migration has already occurred or no legacy install exists.\n+ if let Err(e) = version_manager::migrate_legacy_install(root) {\n+ eprintln!(\"Warning: failed to migrate legacy Nu install: {e:#}\");\n+ }\n```\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\n if let Err(e) = version_manager::migrate_legacy_install(root) {\n eprintln!(\"Warning: failed to migrate legacy Nu install: {e:#}\");\n }\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` at line 24, Update the migration call in the use command\nto handle the Result from version_manager::migrate_legacy_install instead of\ndiscarding it; log or propagate the error with a useful diagnostic, and ensure\nnuman use does not silently continue after migration failure.\n```\n\n
\n\n\n\n\n\n\n\n_Source: MCP tools_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsN","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":101,"originalLine":101,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Switching the active version doesn't refresh cached Nu paths or activation state.**\n\n`execute_latest` and `execute_switch` both call `version_manager::write_active_version` and stop there. Compare this to `src/cmd/nu_pin_offer.rs`'s `install_pinned_nu_and_refresh`, which explicitly calls `init::execute(&InitArgs { refresh: true }, root)` right after switching Nu.\n\nIf `activate`/`deactivate` load a cached `NuPaths` record and validate against the previously recorded executable hash/version, then after `numan use ` the cached record still points at the old Nu binary until the user separately runs `numan init --refresh`. This is a producer-consumer contract gap: `active-version.json` changes, but nothing tells the NuPaths cache to catch up.\n\nCall `init::execute(&InitArgs { refresh: true }, root)` after a successful switch (as `nu_pin_offer.rs` does), or explicitly document that users must run `numan init --refresh` after `numan use`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` around lines 55 - 101, After each successful version\nswitch in execute_latest and execute_switch, invoke init::execute with InitArgs\nconfigured with refresh: true, passing the same root, so cached NuPaths and\nactivation state immediately reflect the new active version. Preserve the\nexisting success messages and error propagation, and apply the refresh only\nafter write_active_version succeeds.\n```\n\n
\n\n\n\n\n\n\n\n_Source: MCP tools_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsP","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":101,"originalLine":101,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Description: Check whether version strings are validated before path construction.\nast-grep run --pattern 'fn version_install_dir($$$) { $$$ }' --lang rust src/nu/version_manager.rs\nast-grep run --pattern 'fn is_version_installed($$$) { $$$ }' --lang rust src/nu/version_manager.rs\nrg -n 'semver::Version::parse|is_ascii|\\.\\.' src/nu/version_manager.rs\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 381\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -e\nprintf '%s\\n' '--- candidate files ---'\nfd -t f 'version_manager|use_cmd|AGENTS' . | sort\nprintf '%s\\n' '--- version manager symbols and relevant source ---'\nast-grep outline src/nu/version_manager.rs 2>/dev/null || true\nsed -n '1,280p' src/nu/version_manager.rs\nprintf '%s\\n' '--- use command and helper call sites ---'\nsed -n '1,150p' src/cmd/use_cmd.rs\nrg -n 'version_install_dir|is_version_installed|write_active_version|active-version|parse_version|normalize' src\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 22597\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -e\nprintf '%s\\n' '--- active binary consumers and version inputs ---'\nrg -n -C 3 'active_nu_binary\\(|version_binary\\(|version_install_dir\\(|write_active_version\\(' src\nprintf '%s\\n' '--- setup/bootstrap version handling ---'\nsed -n '80,125p' src/nu/bootstrap.rs\nsed -n '200,250p' src/nu/bootstrap.rs\nprintf '%s\\n' '--- behavioral path probe (std::path semantics) ---'\npython3 - <<'PY'\nfrom pathlib import PurePosixPath\nroot = PurePosixPath('/app')\nbase = root / 'tools' / 'nushell'\nfor version in ('0.113.1', '../../etc', '../../../etc', '..', 'a/../../../../outside'):\n # Rust PathBuf::join preserves lexical components; display the joined path.\n joined = base / version / 'nu'\n print(f'{version!r} -> {joined}')\nPY\nprintf '%s\\n' '--- tests and validation references ---'\nrg -n -C 2 'Version::parse|semver|version.*(validate|invalid)|invalid.*version|version.*separator' src tests Cargo.toml 2>/dev/null || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 41815\n\n---\n\n\n\n
\n\n**Reject path-like Nu version identifiers before filesystem use.** `version_install_dir` joins `version` without validation, so `../../../etc` can resolve outside `/tools/nushell`; `active_nu_binary` can later return that path. The existing semver parsing only sorts directory names and parses Nu output. Validate the value at the shared version-manager boundary and add traversal tests.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/cmd/use_cmd.rs` around lines 73 - 101, Validate Nu version identifiers at\nthe shared version-manager boundary before any filesystem path construction,\nrejecting path traversal and other path-like values while preserving valid\nversion handling. Update the relevant version-manager functions used by\nexecute_switch, including version_install_dir and active_nu_binary, to rely on\nthis validation, and add tests covering traversal inputs such as ../../../etc\nand valid versions.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsS","isResolved":false,"isOutdated":false,"path":"src/core/resolve.rs","line":317,"originalLine":317,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Correct the `append_nu_mismatch_remediation` documentation.**\n\nThe comment says the message states that PATH is untouched. The message now states only that the existing Nu is not replaced. `setup nu` can update PATH unless `--skip-path` is used, as documented in README.md Line 353. Describe the actual message contract.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/core/resolve.rs` at line 317, Update the documentation comment for\nappend_nu_mismatch_remediation to accurately describe that the message says the\nexisting Nu installation is not replaced, rather than claiming PATH is\nuntouched; retain the documented setup nu context and closing\nbehavior.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsY","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":52,"originalLine":52,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**`if let Ok(Some(..))` hides a corrupt active-version marker.**\n\n`version_manager::active_nu_binary` returns `Err` when `nu_state/active-version.json` exists but cannot be read or parsed (`read_active_version`, `src/nu/version_manager.rs` Lines 33-37). Line 47 discards that error and falls through to the legacy path.\n\nThe result: a corrupt or truncated marker makes Numan silently resolve a different Nu binary. `src/cmd/doctor.rs` uses `managed_nu_binary` at Line 461 and Line 1547, so even `numan doctor` reports a healthy-looking path instead of the real fault. That is fake readiness.\n\nThe `PathBuf` return type cannot carry the error. Add a fallible variant and use it where a diagnostic matters, or at minimum emit the error to stderr before falling back.\n\n\n\n\n\nBased on coding guidelines: \"Error paths must return `anyhow::Result` with context\" and \"Treat the lockfile as the authoritative source of truth; derived projections such as autoload state are not authoritative.\"\n\n
\n🛡️ Minimum fix: surface the error\n\n```diff\n pub fn managed_nu_binary(root: &Path) -> PathBuf {\n- // Try to read the active version; if set, return the versioned binary path.\n- if let Ok(Some(binary)) = crate::nu::version_manager::active_nu_binary(root) {\n- return binary;\n- }\n+ // Try to read the active version; if set, return the versioned binary path.\n+ match crate::nu::version_manager::active_nu_binary(root) {\n+ Ok(Some(binary)) => return binary,\n+ Ok(None) => {}\n+ // A corrupt marker must not resolve silently to a different binary.\n+ Err(e) => eprintln!(\"warning: failed to read active Nu version: {e:#}\"),\n+ }\n // Fallback to legacy path (for backward compat or when no active version is set).\n managed_nu_dir(root).join(nu_binary_name())\n }\n```\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\n/// Returns the path to the currently active Nu binary.\n///\n/// This delegates to `version_manager::active_nu_binary()` which reads the active\n/// version marker and returns the versioned binary path.\npub fn managed_nu_binary(root: &Path) -> PathBuf {\n // Try to read the active version; if set, return the versioned binary path.\n match crate::nu::version_manager::active_nu_binary(root) {\n Ok(Some(binary)) => return binary,\n Ok(None) => {}\n // A corrupt marker must not resolve silently to a different binary.\n Err(e) => eprintln!(\"warning: failed to read active Nu version: {e:#}\"),\n }\n // Fallback to legacy path (for backward compat or when no active version is set).\n managed_nu_dir(root).join(nu_binary_name())\n}\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 41 - 52, Update managed_nu_binary and its\ncallers so errors from version_manager::active_nu_binary, including corrupt\nactive-version markers, are surfaced instead of silently falling back to the\nlegacy path. Prefer adding a fallible variant returning anyhow::Result with\ncontext, then use it in diagnostic paths such as doctor; if the infallible API\nmust remain, emit the underlying error to stderr before applying the fallback.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsb","isResolved":true,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":227,"originalLine":227,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🔴 Critical_ | _⚡ Quick win_\n\n**The version string reaches a filesystem join with no validation.**\n\nLine 225 and Line 227 pass `version` straight into `version_install_dir` and `version_binary`, which call `Path::join`. `version` comes from the CLI positional in `src/cmd/setup.rs` Line 37-39 and flows through `NuSetupOptions.version`.\n\n`Path::join` does not sanitise. A value such as `../../../evil` produces a destination outside `tools/nushell`, and an absolute value replaces the join base entirely. The install then writes an executable outside `$NUMAN_ROOT`.\n\nValidate the version as a strict semantic version before any path join. See the consolidated comment for the shared fix in `src/nu/version_manager.rs`.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 224 - 227, Validate NuSetupOptions.version\nas a strict semantic version before invoking version_install_dir or\nversion_binary in the bootstrap flow. Reuse the shared validation fix and\nsymbols from src/nu/version_manager.rs, reject invalid or path-like values\nearly, and preserve the existing installation behavior for valid versions.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n\n\n✅ Addressed in commit 213e874"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsd","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":240,"originalLine":240,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Archive installation now writes Nu integration state.**\n\nLine 240 calls `write_active_version`, so `install_from_archive` silently switches the active Nu as a side effect of unpacking an archive. That crosses the boundary this repository draws: installation writes payloads, and only the activation lifecycle owns integration state.\n\nThe concrete consequence is state that disagrees with itself. `NuPaths` still records the previous executable and hash. `activate` loads that cached identity and calls `validate_drift`, so the next `numan activate` fails until the user runs `numan init --refresh`. Line 722 prints that hint, which means the code already knows it left the root inconsistent.\n\nReturn the installed version to the caller and let the setup command decide whether to activate it. Then the install step stays a pure payload write.\n\nAlso note the unit test at Lines 876-878 asserts `installed == managed_nu_binary(root)`. That assertion passes only because of this hidden activation write, so the test cannot distinguish the two concerns.\n\n\n\n\n\nBased on coding guidelines: \"Only `activate` and `deactivate` may modify Nu integration state.\" and \"`numan install` must write only to `$NUMAN_ROOT` and must not invoke Nu or register plugins/autoloads.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 237 - 240, Remove the write_active_version\ncall from install_from_archive so archive installation only writes payload files\nand VERSION metadata. Return the installed version from install_from_archive,\nthen have the setup/activation command explicitly activate that returned version\nthrough the existing activation lifecycle. Update the unit test around installed\nand managed_nu_binary to verify installation without relying on hidden\nactive-version state, preserving the rule that only activate and deactivate\nmodify Nu integration state.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJse","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":467,"originalLine":467,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_\n\n**Non-TTY auto-confirm now approves a persistent PATH change.**\n\n`options.yes` is `false` on the `setup nu use` and `setup nu path` paths (`src/cmd/setup.rs` Lines 232-237 and 244-249 set `yes` from the caller, and doctor passes `true`). When `yes` is `false` and stdin is not a terminal, `confirm_or_bail` auto-confirms. Line 471 then calls `persist_path_dir`, which edits the user shell profile or the Windows user PATH.\n\nA PATH edit is externally visible and awkward to reverse. It needs explicit consent, not an inferred one. See the consolidated comment.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/bootstrap.rs` around lines 459 - 467, Update the confirmation flow\nsurrounding confirm_or_bail in the PATH setup logic so non-TTY execution cannot\nimplicitly approve persist_path_dir when options.yes is false. Require an\nexplicit affirmative confirmation for interactive PATH changes, while preserving\nthe existing options.yes behavior for callers that intentionally pass true.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Learnings_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsf","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":16,"originalLine":16,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Is nu_state part of the declared root layout, and does doctor know about it?\nrg -n 'LAYOUT_DIRS' --type=rust -A15\necho '--- other nu_state references ---'\nrg -n 'nu_state' --type=rust\necho '--- documented root layout ---'\nrg -n 'tools/nushell|state/' --glob '*.md' | head -40\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 159\n\n---\n\n🏁 Script executed:\n\n```shell\nset -u\nprintf '%s\\n' '--- tracked Rust files ---'\ngit ls-files '*.rs' | sed -n '1,120p'\nprintf '%s\\n' '--- layout and state references ---'\nrg -n -C 4 'LAYOUT_DIRS|active-version|nu_state|root\\.join\\(\"state\"\\)|state/' --glob '*.rs' . || true\nprintf '%s\\n' '--- relevant files ---'\nfor f in src/nu/version_manager.rs src/util/fs_safety.rs src/cmd/doctor.rs; do\n if [ -f \"$f\" ]; then\n echo \"--- $f ---\"\n cat -n \"$f\" | sed -n '1,240p'\n fi\ndone\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50379\n\n---\n\n🏁 Script executed:\n\n```shell\nprintf '%s\\n' '--- version manager ---'\ncat -n src/nu/version_manager.rs | sed -n '1,240p'\nprintf '%s\\n' '--- doctor layout and Nu-state checks ---'\ncat -n src/cmd/doctor.rs | sed -n '250,430p'\nprintf '%s\\n' '--- active-version call sites ---'\nrg -n -C 5 'active_version|ActiveVersion|load_active|set_active|active-version' --glob '*.rs' . || true\nprintf '%s\\n' '--- doctor tests for active-version and layout ---'\nrg -n -C 5 'active.version|active-version|layout\\.nu_state|nu_state' tests/doctor_test.rs src/cmd/doctor.rs || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 32348\n\n---\n\n🏁 Script executed:\n\n```shell\nprintf '%s\\n' '--- doctor check pipeline ---'\nrg -n 'fn (check_|collect_|execute|run)|check_[a-z_]+\\(' src/cmd/doctor.rs | sed -n '1,180p'\nprintf '%s\\n' '--- version-manager integration ---'\nrg -n 'version_manager|active_nu_binary|read_active_version|write_active_version|active-version' src --glob '*.rs'\nprintf '%s\\n' '--- doctor findings and repair code around the main execution path ---'\nrg -n -C 8 'check_root|check_nu_paths|LAYOUT_DIRS|repair|findings' src/cmd/doctor.rs | sed -n '1,260p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 13533\n\n---\n\n\n\n
\n\n**Add an active-version consistency finding to `numan doctor`.** `nu_state` is already included in `LAYOUT_DIRS`, but `run_checks_with_options` never validates `active-version.json`. Report malformed markers and markers whose `version_binary(root, version)` is missing; allow an absent marker when no version is installed.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 14 - 16, Extend\nrun_checks_with_options with an active-version consistency check for the path\nreturned by active_version_path: tolerate an absent marker when no version is\ninstalled, but report findings for malformed JSON and for markers whose\nversion_binary(root, version) does not exist. Reuse the existing doctor\nfinding/reporting conventions and version discovery logic.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsh","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":38,"originalLine":38,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n**The doc comment promises `None` for an invalid marker, but the code returns `Err`.**\n\nLines 26-27 state: \"Returns `None` if no active version is set (marker doesn't exist or is invalid).\" Line 35-36 returns `Err` on a `serde_json` parse failure, and Line 33-34 returns `Err` on a read failure. Only the missing-file case yields `None`.\n\nThis mismatch already produced a defect. `src/nu/bootstrap.rs` Line 47 writes `if let Ok(Some(binary))`, which discards the parse error and silently falls back to the legacy path.\n\nFix the doc comment to describe the real contract. An unparseable marker is corrupt state and deserves an error, so keep the behaviour and correct the words.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 25 - 38, Update the doc comment for\nread_active_version to state that None is returned only when the marker file is\nmissing, while read failures and invalid JSON return errors. Preserve the\nexisting implementation behavior and error contexts.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsi","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":71,"originalLine":71,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🔴 Critical_ | _⚡ Quick win_\n\n**Validate the version string before you join it into a path.**\n\n`version_install_dir` at Line 64 joins the caller-supplied `version` with no validation, and `version_binary` builds on it. `Path::join` treats `..` as a real parent traversal and lets an absolute component discard the base entirely.\n\nThe reachable write primitive is the install path. `src/nu/bootstrap.rs` Lines 225-238 use these helpers to `create_dir_all`, `std::fs::copy` a binary, `make_executable`, and write a `VERSION` file. The `version` value arrives from the CLI positional in `src/cmd/setup.rs` Lines 37-39. So `numan setup nu ../../../../tmp/pwn` writes an executable outside `$NUMAN_ROOT`. `numan use ` reaches `write_active_version` and `is_version_installed` with the same unvalidated string.\n\nValidate once, here, and reject anything that is not a plain semantic version. Do it in the helper rather than at each call site, so a future caller cannot reintroduce the hole.\n\nTwo smaller points in the same segment. `versioned_nu_dir` at Line 58-60 returns the same path as `bootstrap::managed_nu_dir`; two names for one directory is how the \"remove the managed directory and lose every version\" behaviour stayed invisible. The binary-name expression at Line 69 is repeated at Line 102 and duplicates `bootstrap::nu_binary_name()`; collapse the three into one.\n\n\n\n\n\nBased on coding guidelines: \"Identify and fix security issues when encountered, including injection, XSS, SQL injection, path traversal, and secret leakage.\" and \"`numan install` must write only to `$NUMAN_ROOT`\".\n\n
\n🛡️ Proposed fix: reject non-semver version components\n\n```diff\n+/// Validate a version string that will become a path component.\n+///\n+/// Rejects traversal, separators, and absolute components before any join.\n+pub fn validate_version_component(version: &str) -> Result<()> {\n+ if semver::Version::parse(version).is_err() {\n+ bail!(\"Invalid Nu version '{version}'. Expected a semantic version such as 0.113.1.\");\n+ }\n+ Ok(())\n+}\n+\n /// Directory for a specific Nu version install.\n-pub fn version_install_dir(root: &Path, version: &str) -> PathBuf {\n- versioned_nu_dir(root).join(version)\n-}\n+pub fn version_install_dir(root: &Path, version: &str) -> Result {\n+ validate_version_component(version)?;\n+ Ok(versioned_nu_dir(root).join(version))\n+}\n \n /// Binary path for a specific Nu version.\n-pub fn version_binary(root: &Path, version: &str) -> PathBuf {\n- let binary_name = if cfg!(windows) { \"nu.exe\" } else { \"nu\" };\n- version_install_dir(root, version).join(binary_name)\n-}\n+pub fn version_binary(root: &Path, version: &str) -> Result {\n+ Ok(version_install_dir(root, version)?.join(nu_binary_name()))\n+}\n```\n\nNote that `semver::Version::parse` rejects a bare `0.113`, so confirm every real Nushell release tag parses before you adopt this exact predicate.\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 57 - 71, Update version_install_dir\nto validate version as a plain semantic version before joining it to\nversioned_nu_dir, rejecting traversal, absolute paths, and invalid versions;\nensure callers such as version_binary, bootstrap, and setup propagate the\nrejection. Consolidate versioned_nu_dir with bootstrap::managed_nu_dir and reuse\nbootstrap::nu_binary_name for version_binary instead of duplicating the\ndirectory and binary-name logic. Confirm the validator accepts every supported\nNushell release tag.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsk","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":86,"originalLine":86,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**A dangling active-version marker is indistinguishable from no marker.**\n\nLines 81-85 return `Ok(None)` both when no version was ever selected and when the marker names a version whose binary was deleted. The caller cannot tell a clean root from a broken one.\n\nThat gap is load-bearing. `src/nu/bootstrap.rs` Lines 45-51 treat `None` as \"fall back to the legacy path\", so a root whose active version was removed by `setup nu use` or `setup nu remove` resolves a different Nu binary and reports no problem.\n\nReturn a value that separates the two states, or add a dedicated check that doctor can surface as a finding.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 76 - 86, Update active_nu_binary to\ndistinguish an absent active-version marker from a marker whose version_binary\nno longer exists, preserving Ok(None) only for the no-marker case. Propagate the\nbroken active-version state through the caller in bootstrap and ensure doctor\ncan surface it instead of falling back silently to the legacy binary; use the\nexisting Result/error or diagnostic conventions in the surrounding\nversion-management code.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsl","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":118,"originalLine":118,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🌐 Web query:\n\n`Rust sort_by panic user-provided comparison function does not correctly implement a total order`\n\n💡 Result:\n\nIn Rust, the `sort` and `sort_by` methods require that the comparison logic defines a total order [1][2][3]. If a provided comparison function or the `Ord` implementation for a type violates this requirement, the sorting algorithm may panic [1][2][3]. The requirement for a total order means that for all elements `a`, `b`, and `c`, the following properties must hold: 1. Totality: Exactly one of `a < b`, `a == b`, or `a > b` is true [4][5]. 2. Transitivity: If `a < b` and `b < c`, then `a < c` [4][5]. The same must apply to equality and \"greater than\" relationships [4][5]. Starting with Rust 1.81, the standard library's sorting implementations include checks that can detect when these invariants are broken [1][6]. When such a violation is detected during execution, the library is permitted to panic [1][3]. Even if the function does not panic, if the total order is violated, the resulting order of the elements is unspecified, and any reliance on a specific sorted outcome is incorrect [1][2][3]. This behavior is a deliberate design choice to enforce correctness [7]. Because the sorting algorithms rely on these mathematical invariants to function efficiently and correctly, failing to provide a total order is considered a logic error [4][5]. The panic serves as a signal to the developer that their comparison logic is flawed [6][7]. If you are working with types that do not naturally have a total order (such as floating-point numbers), you must use provided alternatives like `f32::total_cmp` to create a valid total ordering [4][8].\n\nCitations:\n\n- 1: https://github.com/rust-lang/rust/issues/129561\n- 2: https://github.com/rust-lang/rust/issues/136665\n- 3: https://github.com/petrochenkov/rust/commit/8a8c0e25c9d4e242d7caa9b993dee0d02025e485\n- 4: https://doc.rust-lang.org/stable/std/cmp/trait.Ord.html\n- 5: https://doc.rust-lang.org/nightly/core/cmp/trait.Ord.html\n- 6: https://github.com/rust-lang/rust/pull/128273\n- 7: https://users.rust-lang.org/t/why-sort-need-t-to-be-ord/69815\n- 8: https://doc.rust-lang.org/std/cmp/trait.Ord.html\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\nprintf '%s\\n' '--- target function ---'\nsed -n '70,130p' src/nu/version_manager.rs\nprintf '%s\\n' '--- package metadata and call sites ---'\nrg -n -C 3 'rust-version|list_installed_versions|sort_by' Cargo.toml src tests || true\nprintf '%s\\n' '--- repository status ---'\ngit diff --stat\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 13245\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -eu\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"src/nu/version_manager.rs\")\ntext = p.read_text()\nstart = text.index(\"fn list_installed_versions\")\nend = text.find(\"\\n}\", start) + 2\nprint(text[start:end])\nPY\nprintf '%s\\n' '--- metadata and references ---'\nrg -n -C 3 'rust-version|list_installed_versions|sort_by' Cargo.toml src tests || true\nprintf '%s\\n' '--- relevant file status ---'\ngit diff --stat -- src/nu/version_manager.rs Cargo.toml\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 12227\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -eu\ncat > /tmp/check_sort.rs <<'RS'\nuse std::cmp::Ordering;\n\nfn cmp(a: &str, b: &str) -> Ordering {\n match (semver(a), semver(b)) {\n (Some(a), Some(b)) => b.cmp(&a),\n _ => b.cmp(a),\n }\n}\n\nfn semver(s: &str) -> Option<(u64, u64, u64)> {\n let mut it = s.split('.');\n let v = (it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?);\n if it.next().is_some() { None } else { Some(v) }\n}\n\nfn main() {\n let values = [\"0.2.0\", \"0.10.0\", \"bad\"];\n for a in values {\n for b in values {\n println!(\"{a:?} vs {b:?}: {:?}\", cmp(a, b));\n }\n }\n let a = \"0.10.0\";\n let b = \"0.2.0\";\n let c = \"bad\";\n println!(\"transitivity check: cmp(a,b)={:?}, cmp(b,c)={:?}, cmp(a,c)={:?}\",\n cmp(a,b), cmp(b,c), cmp(a,c));\n}\nRS\nrustc /tmp/check_sort.rs -o /tmp/check_sort\n/tmp/check_sort\n```\n\nLength of output: 169\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -eu\nrustc --version\ncat >/tmp/check_inconsistent_sort.rs <<'RS'\nuse std::cmp::Ordering;\n\nfn parse(s: &str) -> Option<(u64, u64, u64)> {\n let mut it = s.split('.');\n let v = (it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?);\n if it.next().is_some() { None } else { Some(v) }\n}\n\nfn cmp(a: &str, b: &str) -> Ordering {\n match (parse(a), parse(b)) {\n (Some(a), Some(b)) => b.cmp(&a),\n _ => b.cmp(a),\n }\n}\n\nfn main() {\n let mut values = vec![\"0.2.0\", \"0.10.0\", \"bad\"];\n values.sort_by(|a, b| cmp(a, b));\n println!(\"{values:?}\");\n}\nRS\nrustc /tmp/check_inconsistent_sort.rs -o /tmp/check_inconsistent_sort\n/tmp/check_inconsistent_sort\n```\n\nLength of output: 168\n\n---\n\n🏁 Script executed (no clone):\n\n```shell:none\n#!/bin/bash\nset -eu\npython3 - <<'PY'\nfrom functools import cmp_to_key\nfrom itertools import permutations\nfrom enum import IntEnum\n\nclass O(IntEnum):\n LESS = -1\n EQUAL = 0\n GREATER = 1\n\ndef semver(s):\n parts = s.split(\".\")\n if len(parts) != 3 or not all(p.isdigit() for p in parts):\n return None\n return tuple(map(int, parts))\n\ndef cmp(a, b):\n av, bv = semver(a), semver(b)\n if av is not None and bv is not None:\n return (bv > av) - (bv < av) # b.cmp(a), descending semver\n return (b > a) - (b < a) # b.cmp(a), reverse lexicographic\n\nvalues = [\"0.10.0\", \"0.2.0\", \"0.15.0x\"]\nprint(\"pairwise comparisons (negative means left sorts first):\")\nfor a, b in permutations(values, 2):\n print(f\"{a} vs {b}: {cmp(a, b):+d}\")\n\na, b, c = values\nprint(\"cycle:\", f\"{a} < {b}:\", cmp(a, b) < 0,\n f\"{b} < {c}:\", cmp(b, c) < 0,\n f\"{c} < {a}:\", cmp(c, a) < 0)\nprint(\"sorted permutations:\")\nfor order in permutations(values):\n actual = list(order)\n actual.sort(key=cmp_to_key(cmp))\n print(order, \"=>\", actual)\nPY\n```\n\nLength of output: 791\n\n---\n\n🌐 Web query:\n\n`site:doc.rust-lang.org/std/primitive.slice.html sort_by comparator total order may panic`\n\n💡 Result:\n\nIn Rust, the sort_by method requires that the provided comparator function defines a total order over the elements being sorted [1][2]. While the official documentation for slice methods does not explicitly state that violating this requirement will trigger a panic, it does define the behavior of sorting functions in terms of the provided closure [1][2]. Specifically, if the provided closure panics, the sorting function will also panic [1][2]. When a comparator fails to implement a total order (for example, by returning inconsistent results for the same pair of elements or violating transitivity), the sorting algorithm may encounter unexpected states, which can lead to logical errors, infinite loops, or undefined behavior, though it does not necessarily guarantee an immediate panic. Developers must ensure that the comparator satisfies the properties of a total order—specifically reflexivity, antisymmetry, transitivity, and totality—to ensure correct and safe execution of the sort.\n\nCitations:\n\n- 1: https://doc.rust-lang.org/std/primitive.slice.html?search=sort\n- 2: https://doc.rust-lang.org/std/primitive.slice.html\n\n---\n\n\n\n
\n\n**Use a consistent ordering for all version names.**\n\nThe comparator mixes semantic-version and lexicographic ordering. `0.10.0`, `0.2.0`, and invalid name `0.15.0x` create a comparison cycle. `sort_by` may panic or return an unspecified order. Parse each version once, sort parsed versions first, and sort unparsable names separately. Add error context to directory-entry failures and a regression test.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 109 - 118, Update the version-sorting\nflow in the surrounding version manager function to parse each name once,\npartition valid semantic versions from unparsable names, sort valid versions by\nsemantic version descending and invalid names lexicographically using separate\ngroups, then combine them without a mixed comparator. Add contextual error\ninformation when reading directory entries, and add a regression test covering\nvalid and invalid names such as 0.10.0, 0.2.0, and 0.15.0x.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsn","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":160,"originalLine":160,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_\n\n**Migration reads then renames with no mutation lock.**\n\nLines 146-160 check that the legacy binary exists and that no versioned directory exists. Line 195 renames the binary, and Line 204 writes the active-version marker. Nothing between those points holds a lock.\n\nA concurrent `numan setup nu` or a second `numan use` can pass the same check and then both act. The result is a lost binary or an active-version marker that names a directory the other process removed. This is a plain time-of-check to time-of-use window on the state that decides which Nu every later command runs.\n\nAcquire the mutation lock for the whole migration, and snapshot before the rename.\n\nTwo smaller points. Line 207 calls `.unwrap()` on `parent()`; library code should not panic even when the value is provably `Some`. That same line calls `remove_dir` on `tools/nushell`, which is the parent of the versioned directory you just created, so the call always fails and the error is discarded. Drop it or target the intended directory.\n\n\n\n\n\nBased on coding guidelines: \"Every new mutating code path must acquire the mutation lock and snapshot the lockfile before writes.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 145 - 160, Update the migration\nroutine containing the legacy_binary and versioned_nu_dir checks to acquire the\nmutation lock and snapshot the lockfile before any migration writes, holding the\nlock through the rename and active-version marker update. Replace the\nparent().unwrap() usage with non-panicking handling, and remove or correct the\nremove_dir call targeting tools/nushell so it does not silently attempt to\nremove the parent of the newly created versioned directory.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsp","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":179,"originalLine":179,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🩺 Stability & Availability_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**Migration spawns a Nu binary with no timeout, from a non-activation code path.**\n\nLines 163-171 execute the legacy binary and block on `output()`. There is no timeout, so a hung or non-terminating binary blocks the command forever. `output()` also reads stdout and stderr to EOF, so a binary that writes without exiting stalls the caller.\n\nTwo further concerns:\n\n- This runs outside the activation lifecycle. The repository restricts Nu invocation to the activate and deactivate boundary, and `numan use` is not that boundary.\n- The binary is whatever sits at `tools/nushell/nu`. If the root is writable by another process, migration executes it and trusts its stdout to pick a directory name that becomes a filesystem path.\n\nRead the version from the `VERSION` file when it exists, which `src/nu/bootstrap.rs` Line 238 already writes. If you must spawn, add a wall-clock timeout and make the step explicit to the user rather than implicit inside `numan use`.\n\n\n\n\n\nBased on coding guidelines: \"Only `activate` and `deactivate` may modify Nu integration state.\" and \"External-call hazards ... blocking calls without timeouts on request threads\".\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 162 - 179, Replace the legacy binary\nexecution in the version-detection flow with reading the existing VERSION file\nwritten by bootstrap, avoiding Nu invocation during numan use and preserving the\ndetected version behavior. If a fallback spawn remains necessary, isolate it\nbehind an explicit user-triggered step and enforce a wall-clock timeout before\ncollecting output; never trust unvalidated executable output as a filesystem\npath.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsq","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":317,"originalLine":317,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**The riskiest function in this module has no tests, because it cannot be tested as written.**\n\nThe suite covers the marker roundtrip, listing, the installed check, and version parsing. It does not cover `migrate_legacy_install`, `active_nu_binary`, or `latest_installed_version`.\n\n`migrate_legacy_install` spawns a real process at Line 163, so no unit test can exercise it without a real `nu` on disk. That is the signal to inject a seam. Take the version detector as a parameter, then a fake returns a canned version string and the test asserts the rename, the marker write, and the skip-when-versioned-dirs-exist branch.\n\nAlso add a `list_installed_versions` case that mixes a valid version with a non-version directory name such as `.tmp`. That case exercises the comparator path flagged at Lines 109-118, and it fails today.\n\n\n\n\n\nBased on coding guidelines: \"Unit tests must use injectable seams such as `FakeCandidateRunner` and registrars; do not spawn real `nu` in unit tests.\" and \"Behavior changes require corresponding tests, including relevant failure paths.\"\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/nu/version_manager.rs` around lines 237 - 317, Make\nmigrate_legacy_install testable by injecting its\nversion-detection/process-running dependency instead of spawning a real nu\nbinary, then add tests using a fake detector to verify directory renaming,\nactive-version marker creation, and skipping migration when versioned\ndirectories already exist. Extend test_list_installed_versions_with_versions\nwith a non-version directory such as .tmp alongside valid versions, preserving\nthe expected sorted valid-version output.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJss","isResolved":false,"isOutdated":false,"path":"src/util/confirm.rs","line":31,"originalLine":31,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🔴 Critical_ | _🏗️ Heavy lift_\n\n**Non-TTY auto-confirm removes consent from destructive and hard-to-reverse operations.** The shared root cause is `confirm_or_auto`: it returns `Ok(true)` whenever stdin is not a terminal, with no way for a caller to say \"this action is destructive, refuse instead\". Every caller inherits that single policy, so deletions and PATH edits now proceed unattended in any piped or CI invocation. Add an explicit non-TTY policy and route destructive callers through the refusing variant.\n- `src/util/confirm.rs#L12-L31`: add a non-TTY policy parameter, and bail with a message that points at `--yes` when the policy is to refuse.\n- `src/cmd/snapshot.rs#L242-L244`: pass the refusing policy, so `snapshot delete` and `snapshot rollback` cannot delete or roll back without an explicit `--yes`.\n- `src/nu/bootstrap.rs#L459-L467`: pass the refusing policy, so `persist_path_dir` never edits the user shell profile or Windows user PATH without an explicit `--yes`.\n\n
\n📍 Affects 3 files\n\n- `src/util/confirm.rs#L12-L31` (this comment)\n- `src/cmd/snapshot.rs#L242-L244`\n- `src/nu/bootstrap.rs#L459-L467`\n\n
\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/util/confirm.rs` around lines 12 - 31, Update confirm_or_auto to accept\nan explicit non-TTY policy, refusing with a message directing users to --yes\nwhen configured to refuse while preserving auto-confirm behavior for other\ncallers; pass the refusing policy at src/util/confirm.rs:12-31,\nsrc/cmd/snapshot.rs:242-244, and src/nu/bootstrap.rs:459-467 so snapshot\ndelete/rollback and persist_path_dir require explicit --yes in non-TTY\nenvironments.\n```\n\n
\n\n\n\n\n\n\n\n\n\n\n\n_Source: Learnings_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJst","isResolved":false,"isOutdated":false,"path":"src/util/confirm.rs","line":57,"originalLine":57,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Tests cover only the `yes = true` short circuit.**\n\nBoth tests pass `yes = true`, which returns before the TTY check and the read. The non-TTY branch and the decline branch have no unit coverage here. The behavior change in this PR is exactly in those branches.\n\nExtract the decision into a testable seam that takes the TTY state and an input reader. Then assert the non-TTY policy and the `y`/`n`/empty-input parsing directly.\n\n\n\n\n\nBased on coding guidelines: \"Behavior changes require corresponding tests, including relevant failure paths.\" and \"Unit tests must use injectable seams such as `FakeCandidateRunner` and registrars\".\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/util/confirm.rs` around lines 44 - 57, Extend the confirm logic around\nconfirm_or_auto and confirm_or_bail by extracting the decision flow into an\ninjectable seam that accepts explicit TTY state and an input reader. Add unit\ntests covering the non-TTY policy plus y, n, and empty-input parsing, while\nretaining the existing yes=true short-circuit tests and behavior.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsu","isResolved":false,"isOutdated":false,"path":"src/util/hints.rs","line":37,"originalLine":37,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_🔒 Security & Privacy_ | _🟡 Minor_ | _⚡ Quick win_\n\n
\n🧩 Analysis chain\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\nrg -n -C 4 'setup_nu_use_existing|display\\(\\)|quote|escape' src tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50378\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- src/util/hints.rs ---'\ncat -n src/util/hints.rs\nprintf '%s\\n' '--- setup_nu_use_existing references ---'\nrg -n -C 8 'setup_nu_use_existing|setup nu use|--use-existing|CMD_SETUP' src tests Cargo.toml\nprintf '%s\\n' '--- relevant CLI definitions ---'\nrg -n -C 6 'UseExisting|use_existing|use-existing|Setup|setup' src/cli.rs src/main.rs src\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50377\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\npython3 - <<'PY'\nfrom pathlib import Path\np = Path(\"src/util/hints.rs\")\nprint(\"hints.rs bytes:\", p.stat().st_size)\nprint(\"hints.rs lines:\", len(p.read_text().splitlines()))\nPY\nprintf '%s\\n' '--- exact function and callers ---'\nrg -n -C 12 'setup_nu_use_existing' src tests || true\nprintf '%s\\n' '--- command parsing references ---'\nrg -n -C 5 --glob '*.rs' 'use_existing|use-existing|SetupNu|Setup.*Nu|Nu.*Use' src tests || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 26669\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- tests/setup_nu_test.rs ---'\ncat -n tests/setup_nu_test.rs | sed -n '1,180p'\nprintf '%s\\n' '--- command/help text mentioning the hint ---'\nrg -n -C 8 'Nu not found|use-existing|setup nu' src tests docs README.md 2>/dev/null || true\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 50379\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- doctor output rendering ---'\nrg -n -C 10 'fix|Finding|format.*report|print.*finding|findings' src/cmd/doctor.rs src | head -n 240\nprintf '%s\\n' '--- off-path resolution and allowed roots ---'\nrg -n -C 12 'fn resolve_off_path|resolve_off_path|discover_nu_off_path|known.*root|cargo/bin|LOCALAPPDATA' src\nprintf '%s\\n' '--- existing quoting/escaping helpers ---'\nrg -n -C 5 'shell.*quote|quote.*shell|escape.*shell|escape.*path|single.?quote|double.?quote|shellwords|shlex' src tests\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 39164\n\n---\n\n🌐 Web query:\n\n`Nushell official documentation quoting paths external commands semicolon metacharacters single quotes double quotes`\n\n💡 Result:\n\nIn Nushell, external commands, paths, quoting, and metacharacters behave according to specific rules designed for its unique architecture as a structured data shell. External Commands To run an external system command (a binary or application), you can call it directly by name. If the command name conflicts with a built-in Nushell command, or if you want to explicitly signal an external call, use the caret (^) sigil (e.g., ^ls) [1][2]. The run-external command provides a way to invoke external binaries, with support for features like redirecting stdout and stderr [3]. Nushell uses the PATH environment variable (automatically converted into a list) to locate these executables [2]. Paths Nushell handles paths as strings, often requiring quotes if they contain spaces or special characters (such as glob patterns) [4][5]. Using raw strings or single-quoted strings is recommended for paths, especially on Windows, as they preserve backslashes literally without needing escapes [6][5]. Semicolon Metacharacters A semicolon (;) acts as a command separator, similar to a newline in a Nushell expression [7]. It is not a POSIX-style logical operator for chaining commands based on success or failure [8]. When using a semicolon, the output of the preceding command is discarded, and the $in variable will not capture data from the previous statement [9]. Quoting Rules Nushell provides several ways to quote strings, each with distinct behaviors: - Single-quoted strings ('...'): Treat content as completely literal; they do not support escape sequences and are ideal for paths or simple strings [6][5]. - Double-quoted strings (\"...\"): Support C-style backslash escapes (e.g., \\n, \\t, \\\") and allow for variable interpolation using parentheses, such as \"Value is ($var)\" [6][10]. - Raw strings (r#'... '#): Allow you to include single or double quotes without needing to escape them; they are highly recommended for complex strings or multi-line content [6][5]. - Backtick-quoted strings (`...`): Primarily used for paths or arguments that may contain spaces or glob patterns, as backticks interpret asterisks (*) and other characters as globs rather than literals [4][5]. - Bare words: Strings without quotes (e.g., hello) are interpreted as commands or arguments if they contain only \"word\" characters, but they cannot be used in all positions (such as command positions if reserved) [6][5]. Interpolation Interpolated strings ($'...' or $\"...\") allow you to embed variables or expressions directly into strings. Single-quoted interpolation is literal (except for the interpolation itself), while double-quoted interpolation supports C-style escape sequences [6][10].\n\nCitations:\n\n- 1: https://www.nushell.sh/book/running_externals.html\n- 2: https://www.nushell.sh/book/configuration\n- 3: https://www.nushell.sh/commands/docs/run-external.html\n- 4: https://www.nushell.sh/book/moving_around.html\n- 5: https://github.com/nushell/nushell/blob/90d82b7c/crates/nu-mcp/src/instructions.md\n- 6: https://www.nushell.sh/book/working_with_strings.html\n- 7: https://www.nushell.sh/book/thinking_in_nu.html\n- 8: https://github.com/nushell/nushell/issues/13354\n- 9: http://www.nushell.sh/book/pipelines.html\n- 10: https://www.nushell.sh/lang-guide/chapters/strings_and_text.html\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\nprintf '%s\\n' '--- targeted doctor/report symbols ---'\nrg -n '^pub fn |^fn |format!|println!|writeln!' src/cmd/doctor.rs | sed -n '1,220p'\nprintf '%s\\n' '--- focused report sections ---'\nsed -n '560,760p' src/cmd/doctor.rs\nsed -n '1000,1110p' src/cmd/doctor.rs\nprintf '%s\\n' '--- path discovery symbols ---'\nrg -n '^pub fn |^fn |OFF_PATH|off_path|known' src/nu/paths.rs | sed -n '1,180p'\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 15300\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\npython3 - <<'PY'\nimport shlex\n\ndef current(path):\n return f\"numan setup nu use {path}\"\n\ncases = [\n r\"C:\\Program Files\\Nu\\nu.exe\",\n \"/tmp/nu; touch /tmp/unintended\",\n \"/tmp/nu$(id)\",\n \"/tmp/nu`id`\",\n \"/tmp/user's nu\",\n]\nfor path in cases:\n command = current(path)\n print(f\"path={path!r}\")\n print(f\"command={command!r}\")\n print(f\"posix_tokens={shlex.split(command, posix=True)!r}\")\n print(f\"contains_shell_operator={any(c in path for c in ';|&<>$`()\\\\n')}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 408\n\n---\n\n🏁 Script executed:\n\n```shell\nset -euo pipefail\npython3 - <<'PY'\nimport shlex\n\ndef current(path):\n return f\"numan setup nu use {path}\"\n\noperator_chars = set(\";|&<>$()`\\n\")\ncases = [\n r\"C:\\Program Files\\Nu\\nu.exe\",\n \"/tmp/nu; touch /tmp/unintended\",\n \"/tmp/nu$(id)\",\n \"/tmp/nu`id`\",\n \"/tmp/user's nu\",\n]\nfor path in cases:\n command = current(path)\n print(f\"path={path!r}\")\n print(f\"command={command!r}\")\n print(f\"posix_tokens={shlex.split(command, posix=True)!r}\")\n print(f\"contains_shell_operator={any(c in operator_chars for c in path)}\")\nPY\n```\n\nRepository: tonythethompson/numan\n\nLength of output: 1417\n\n---\n\n\n\n
\n\n**Quote the path in the generated command.**\n\n`Path::display()` leaves spaces and shell metacharacters unescaped. A copied command can split the path or execute unintended syntax. Use shell-safe quoting and add tests for spaces, quotes, backslashes, and metacharacters.\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/util/hints.rs` around lines 35 - 37, Update setup_nu_use_existing to\nshell-quote the path before interpolating it into the generated command,\npreserving the existing command structure while safely handling spaces, quotes,\nbackslashes, and shell metacharacters. Add tests covering each of those path\ncases and verify the generated command is safe to copy and execute.\n```\n\n
\n\n\n\n\n\n\n\n_Source: Coding guidelines_\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpJsv","isResolved":false,"isOutdated":false,"path":"tests/setup_nu_test.rs","line":244,"originalLine":244,"comments":{"nodes":[{"author":{"login":"coderabbitai"},"body":"_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_\n\n**Use the constructor for consistency with the sibling test.**\n\nThis test builds `NuSetupArgs` by listing all 8 fields manually. The sibling test at Line 269 builds the analogous invalid state with `NuSetupArgs::install(...)` and then overrides one field. Use the same pattern here for consistency and less duplication.\n\n\n\n
\n♻️ Proposed refactor\n\n```diff\n- let args = NuSetupArgs {\n- action: Some(numan_cli::cmd::setup::NuAction::Use { path: existing }),\n- version: None,\n- force: false,\n- skip_path: true,\n- yes: true,\n- remove: false,\n- use_path: false,\n- use_existing: None,\n- };\n+ let mut args = NuSetupArgs::use_existing(existing, true);\n+ args.skip_path = true;\n```\n
\n\n\n\n
\n📝 Committable suggestion\n\n> ‼️ **IMPORTANT**\n> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.\n\n```suggestion\n let mut args = NuSetupArgs::use_existing(existing, true);\n args.skip_path = true;\n```\n\n
\n\n\n\n
\n🤖 Prompt for AI Agents\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@tests/setup_nu_test.rs` around lines 235 - 244, Update the test’s NuSetupArgs\nconstruction to use the existing NuSetupArgs::install(...) constructor, then\noverride the fields needed for the NuAction::Use case, matching the sibling\ntest’s pattern and preserving the current test state.\n```\n\n
\n\n\n\n\n\n\n\n"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoU","isResolved":false,"isOutdated":false,"path":"src/cmd/snapshot.rs","line":243,"originalLine":243,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: Piped/CI snapshot delete and rollback now proceed without `--yes`, so destructive operations no longer require an explicit noninteractive opt-in. Preserve this command's prior non-TTY rejection before using the shared prompt helper.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/snapshot.rs, line 243:\n\nPiped/CI snapshot delete and rollback now proceed without `--yes`, so destructive operations no longer require an explicit noninteractive opt-in. Preserve this command's prior non-TTY rejection before using the shared prompt helper.\n\n\n@@ -241,17 +240,7 @@ fn rollback(root: &Path, id: &str, yes: bool) -> Result<()> {\n- bail!(\"Cancelled.\");\n- }\n- Ok(())\n+ crate::util::confirm::confirm_or_bail(prompt, false, \"Cancelled.\")\n }\n \n\n```\n\n
\n\n```suggestion\n if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {\n anyhow::bail!(\"Interactive confirmation required for non-TTY sessions. Pass --yes to proceed without prompting.\");\n }\n crate::util::confirm::confirm_or_bail(prompt, false, \"Cancelled.\")\n```"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoY","isResolved":false,"isOutdated":false,"path":"src/cmd/setup.rs","line":231,"originalLine":231,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: `setup nu use ` deletes every managed Nu version before validating or confirming the replacement. An invalid path or declining the later `Proceed?` prompt leaves the user with no managed Nu; validate and obtain confirmation before removing the managed directory.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/setup.rs, line 231:\n\n`setup nu use ` deletes every managed Nu version before validating or confirming the replacement. An invalid path or declining the later `Proceed?` prompt leaves the user with no managed Nu; validate and obtain confirmation before removing the managed directory.\n\n\n@@ -78,27 +159,146 @@ pub fn execute_nu(args: &NuSetupArgs, root: &Path) -> Result<()> {\n+ }\n }\n \n+ remove_managed_nu_if_present(root)?;\n let options = NuSetupOptions {\n- yes: args.yes,\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLob","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":32,"originalLine":32,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: A path-like version can select and persist an executable outside the managed version directory. Require a valid SemVer version before passing it to the path-building helpers.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/use_cmd.rs, line 29:\n\nA path-like version can select and persist an executable outside the managed version directory. Require a valid SemVer version before passing it to the path-building helpers.\n\n\n@@ -0,0 +1,199 @@\n+ match args.version.as_str() {\n+ \"list\" => execute_list(root),\n+ \"latest\" => execute_latest(root),\n+ version => execute_switch(root, version),\n+ }\n+}\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLof","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":47,"originalLine":47,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP1: Installing another version with `--yes` is skipped whenever any active version exists. Check the requested version's `version_binary` path (or bypass the existing-install short circuit when it differs) so side-by-side installs actually run.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/nu/bootstrap.rs, line 47:\n\nInstalling another version with `--yes` is skipped whenever any active version exists. Check the requested version's `version_binary` path (or bypass the existing-install short circuit when it differs) so side-by-side installs actually run.\n\n\n@@ -39,7 +38,16 @@ pub fn managed_nu_dir(root: &Path) -> PathBuf {\n+/// version marker and returns the versioned binary path.\n pub fn managed_nu_binary(root: &Path) -> PathBuf {\n+ // Try to read the active version; if set, return the versioned binary path.\n+ if let Ok(Some(binary)) = crate::nu::version_manager::active_nu_binary(root) {\n+ return binary;\n+ }\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoh","isResolved":false,"isOutdated":false,"path":"src/nu/version_manager.rs","line":43,"originalLine":43,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP2: Concurrent `numan use`/`setup nu` operations can interleave active-version updates with install or migration, leaving the marker pointed at a version whose move/copy is not complete. Serialize this state mutation with the root mutation lock, consistent with other managed-Nu mutations.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/nu/version_manager.rs, line 41:\n\nConcurrent `numan use`/`setup nu` operations can interleave active-version updates with install or migration, leaving the marker pointed at a version whose move/copy is not complete. Serialize this state mutation with the root mutation lock, consistent with other managed-Nu mutations.\n\n\n@@ -0,0 +1,317 @@\n+}\n+\n+/// Write the active Nu version to the marker file.\n+pub fn write_active_version(root: &Path, version: &str) -> Result<()> {\n+ let path = active_version_path(root);\n+ // Ensure parent directory exists.\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLok","isResolved":false,"isOutdated":false,"path":"src/cmd/use_cmd.rs","line":23,"originalLine":23,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP2: Concurrent `numan setup nu` and `numan use` can race over the active marker or legacy binary, leaving the last writer to decide activation. Acquire the root mutation lock before migration or any switch operation.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cmd/use_cmd.rs, line 21:\n\nConcurrent `numan setup nu` and `numan use` can race over the active marker or legacy binary, leaving the last writer to decide activation. Acquire the root mutation lock before migration or any switch operation.\n\n\n@@ -0,0 +1,199 @@\n+ pub version: String,\n+}\n+\n+pub fn execute(args: &UseArgs, root: &Path) -> Result<()> {\n+ // Attempt migration of legacy single-binary install before any operation.\n+ // This is a no-op if migration has already occurred or no legacy install exists.\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLom","isResolved":false,"isOutdated":false,"path":"src/nu/bootstrap.rs","line":240,"originalLine":240,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP2: A failed binary validation still switches the active marker to that invalid binary. Validate `dest` before writing the marker, so failed installs retain the previously active Nu version.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/nu/bootstrap.rs, line 240:\n\nA failed binary validation still switches the active marker to that invalid binary. Validate `dest` before writing the marker, so failed installs retain the previously active Nu version.\n\n\n@@ -225,7 +234,10 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) ->\n+ // Also write a VERSION file in the version directory for reference.\n std::fs::write(dest_dir.join(\"VERSION\"), version.as_bytes())?;\n+ // Set as the active version.\n+ crate::nu::version_manager::write_active_version(root, version)?;\n Ok(dest)\n }\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLoo","isResolved":false,"isOutdated":false,"path":"docs/plans/consolidated-multi-repo-roadmap.md","line":327,"originalLine":327,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP3: The roadmap section updated in this PR describes `numan use` as a post-1.0 reserved stub whose `` form \"auto-installs if missing\", with an active marker at `/tools/nushell/active` and PATH/shim management. The same PR actually implements a fully working `numan use |latest|list` command that does NOT auto-download (it errors with a hint to run `setup nu --version`) and stores its active marker at `/nu_state/active-version.json` with no shim. Since this roadmap is the single cross-repo plan others (plugin backfill waves, activation-keying, registry work) rely on, the stale \"reserved stub\" claim and the auto-install behavior contradict the shipped design and can mislead downstream planning. Recommend updating the roadmap to describe the implemented behavior (set active marker only, no auto-install, active-version.json location) and removing the \"exists as a stub\" line.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At docs/plans/consolidated-multi-repo-roadmap.md, line 327:\n\nThe roadmap section updated in this PR describes `numan use` as a post-1.0 reserved stub whose `` form \"auto-installs if missing\", with an active marker at `/tools/nushell/active` and PATH/shim management. The same PR actually implements a fully working `numan use |latest|list` command that does NOT auto-download (it errors with a hint to run `setup nu --version`) and stores its active marker at `/nu_state/active-version.json` with no shim. Since this roadmap is the single cross-repo plan others (plugin backfill waves, activation-keying, registry work) rely on, the stale \"reserved stub\" claim and the auto-install behavior contradict the shipped design and can mislead downstream planning. Recommend updating the roadmap to describe the implemented behavior (set active marker only, no auto-install, active-version.json location) and removing the \"exists as a stub\" line.\n\n\n@@ -287,9 +285,58 @@ Ship 1.0 when **all** of the following are true:\n+Source: awesome-nu + manual discovery. Entries marked `NEEDS_RESEARCH` need\n+their version history filled in before promotion.\n+\n+**Command reserved:** `numan use` exists as a stub in 0.1.x (prints a\n+\"planned post-1.0\" message) so the UX shape is locked.\n+\n\n```\n\n
"}]}},{"id":"PRRT_kwDOTGvfHs6VpLor","isResolved":false,"isOutdated":false,"path":"src/cli.rs","line":64,"originalLine":64,"comments":{"nodes":[{"author":{"login":"cubic-dev-ai"},"body":"\n\n\nP3: The user-facing help text for `numan use` says it is \"reserved, post-1.0\", but the command is fully implemented and exposed as a working subcommand (`use `, `use latest`, `use list`). This contradicts the actual behavior users see in `numan --help` and could make them think the feature is unavailable. Recommend dropping the \"(reserved, post-1.0)\" suffix or rewording the doc comment so the generated help matches the implemented functionality.\n\n
\nPrompt for AI agents\n\n```text\nCheck if this issue is valid ΓÇö if so, understand the root cause and fix it. At src/cli.rs, line 64:\n\nThe user-facing help text for `numan use` says it is \"reserved, post-1.0\", but the command is fully implemented and exposed as a working subcommand (`use `, `use latest`, `use list`). This contradicts the actual behavior users see in `numan --help` and could make them think the feature is unavailable. Recommend dropping the \"(reserved, post-1.0)\" suffix or rewording the doc comment so the generated help matches the implemented functionality.\n\n\n@@ -61,4 +61,6 @@ pub enum Commands {\n Setup(cmd::setup::SetupCommands),\n /// Install and activate a starter package that fits your Nu\n Try(cmd::try_cmd::TryArgs),\n+ /// Switch the active managed Nu version (reserved, post-1.0)\n+ Use(cmd::use_cmd::UseArgs),\n }\n\n```\n\n
\n\n```suggestion\n /// Switch the active managed Nu version\n```"}]}}]}}}}} From 25910366c35bcf61f36b420a92212c04c02fcd02 Mon Sep 17 00:00:00 2001 From: Anthony Thompson Date: Sat, 1 Aug 2026 20:11:35 -0700 Subject: [PATCH 11/61] Update src/cmd/snapshot.rs Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- src/cmd/snapshot.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cmd/snapshot.rs b/src/cmd/snapshot.rs index 6e824822..93dfc68a 100644 --- a/src/cmd/snapshot.rs +++ b/src/cmd/snapshot.rs @@ -240,6 +240,9 @@ fn rollback(root: &Path, id: &str, yes: bool) -> Result<()> { } fn confirm(prompt: &str) -> Result<()> { + if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { + anyhow::bail!("Interactive confirmation required for non-TTY sessions. Pass --yes to proceed without prompting."); + } crate::util::confirm::confirm_or_bail(prompt, false, "Cancelled.") } From d9b8e0f9fd6b3c3f28b4cd4b94f1b63696ef05d5 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sat, 1 Aug 2026 20:17:47 -0700 Subject: [PATCH 12/61] fix: PR 67 review - bootstrap yes flag, version check, mutation lock --- src/cmd/use_cmd.rs | 5 +++++ src/nu/bootstrap.rs | 23 ++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/cmd/use_cmd.rs b/src/cmd/use_cmd.rs index e0ea9989..2807664d 100644 --- a/src/cmd/use_cmd.rs +++ b/src/cmd/use_cmd.rs @@ -11,6 +11,7 @@ use clap::Args; use std::path::Path; use crate::nu::version_manager; +use crate::util::fs_safety::acquire_mutation_lock; #[derive(Args, Debug)] pub struct UseArgs { @@ -20,6 +21,10 @@ pub struct UseArgs { } pub fn execute(args: &UseArgs, root: &Path) -> Result<()> { + // Hold the mutation lock for the entire operation to prevent races + // between concurrent `numan setup nu` and `numan use` invocations. + let _lock = acquire_mutation_lock(root)?; + // Attempt migration of legacy single-binary install before any operation. // This is a no-op if migration has already occurred or no legacy install exists. version_manager::migrate_legacy_install(root) diff --git a/src/nu/bootstrap.rs b/src/nu/bootstrap.rs index b9e334f8..f260027f 100644 --- a/src/nu/bootstrap.rs +++ b/src/nu/bootstrap.rs @@ -665,7 +665,24 @@ where .map(normalize_release_tag) .unwrap_or_else(|| "latest".to_string()); - if dest.is_file() && !options.force { + // When an active version already matches the request, skip the download. + // If a different version was requested, fall through to the full install flow. + let active_matches_request = || -> bool { + let active = match crate::nu::version_manager::read_active_version(root) { + Ok(Some(a)) => a, + _ => return false, + }; + match &options.version { + Some(requested) => { + crate::nu::version_manager::normalize_version(requested) + .map(|v| v == active.version) + .unwrap_or(false) + } + None => true, // "latest" with any active version → skip + } + }; + + if dest.is_file() && !options.force && active_matches_request() { if options.yes { let tools_dir = managed_nu_dir(root); prepend_process_path(&tools_dir)?; @@ -684,7 +701,7 @@ where "Nushell is already installed at '{}'. Reinstall {version_label} release?", dest.display() ), - false, + options.yes, "Nushell setup cancelled.", )?; } @@ -721,7 +738,7 @@ where println!("Next steps:"); println!(" {}", crate::util::hints::CMD_INIT_REFRESH); println!(" numan doctor"); - println!(" Re-activate packages you still want: numan activate --yes"); + println!(" Re-activate packages you still want: numan activate"); Ok(installed) } From e24523277e20957159b474ee78b16dff8d174a54 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sat, 1 Aug 2026 21:07:05 -0700 Subject: [PATCH 13/61] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 073b4973..3914e957 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ desktop.ini # Scratch .tmp/ /.qoder +/.freebuff From 641bd24f9dc53ef06d0a5c8c2d3fca02d06878a1 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Sun, 2 Aug 2026 03:26:00 -0700 Subject: [PATCH 14/61] wip(numan-use): integrate use + migration pieces (split pending) wip: puts numan use + confirm-gate UX + migration journal + reconcile into a single baseline commit so the pr-migrate-legacy-installs split can branch off a known starting point. This commit does not represent either final PR; the next step surgically extracts migration into its own branch and reverts the migration hunks on feature/numan-use. --- AGENTS.md | 3 + docs/plans/consolidated-multi-repo-roadmap.md | 9 +- src/cmd/doctor.rs | 123 ++- src/cmd/setup.rs | 284 +++++- src/cmd/use_cmd.rs | 13 + src/nu/bootstrap.rs | 129 ++- src/nu/paths.rs | 137 ++- src/nu/version_manager.rs | 870 ++++++++++++++++-- src/state/migration_journal.rs | 462 ++++++++++ src/state/mod.rs | 1 + src/util/confirm.rs | 42 + src/util/hints.rs | 6 + 12 files changed, 1993 insertions(+), 86 deletions(-) create mode 100644 src/state/migration_journal.rs diff --git a/AGENTS.md b/AGENTS.md index 0cc96e5a..0ba36fc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ src/ autoload_recovery.rs — Command-independent PendingAutoload reconciliation into lockfile + derived autoload state autoload_state.rs — Derived autoload-state projection (NOT authoritative; lockfile is ground truth) (Phase 4) lifecycle_journal.rs — pending-lifecycle.json for update/remove/nupm_import crash recovery (Phase 5–6) + migration_journal.rs — `state/migration-journal.json` for legacy-Nu single-binary → versioned-layout transition (Prepared → Renamed → Active stages); self-heal at top of `migrate_legacy_install_with_detector`, reconciled by `numan doctor --fix` (auto-tier repair) snapshot.rs — Immutable activation snapshots (`create_snapshot`, `list_snapshots`, etc.) rollback.rs — Journaled restore of Numan-owned state to a snapshot nupm_import.rs — nupm-import provenance (`state/nupm-imports.json`, Phase 6.2) @@ -141,6 +142,8 @@ tests/ - **Activation scope**: `PluginActivation` struct stores `(nu_executable_sha256, nu_version, plugin_registry_path)`; a plugin is "active" only when all three match the current `NuPaths` — bare `bool` would go stale after `numan init --refresh` - **Journal**: `state/pending-activation.json` written as all-`prepared` before first registration; each entry advances to `registered` atomically before lockfile update; reconciled on next `activate` run if process is interrupted - **Plugin deactivate journal**: `state/pending-plugin-deactivate.json` (`Prepared` → `Unregistered` → clear lockfile `activation`); reconciled on next `deactivate`; doctor warns `journal.plugin_deactivate_pending` +- **Migration journal**: `state/migration-journal.json` for the legacy-Nu single-binary → versioned layout transition. Stages `Prepared` (before `create_dir_all`) → `Renamed` (after legitimate `rename`) → `Active` (after `write_active_version`); journal deleted on transition to `Active`. `Prepared`-only entries are reconciled by `numan doctor --fix` (Auto-tier, fix hint `numan use`) and by the self-healing `reconcile(root)?` at the top of every `migrate_legacy_install_with_detector` call; file-system truth takes precedence over journal stage when they disagree. +- **Active version marker**: `nu_state/active-version.json` (`{ "version": "X.Y.Z" }`, optionally `{ "version": "X.Y.Z", "binary_path": "/abs/path/to/nu" }` for off-tree selections). Sole authority for which `tools/nushell//` is selected. Written by `numan setup nu` and `numan use |latest`. The optional `binary_path` records the resolved off-tree binary when `numan setup nu use ` swaps to a user-supplied Nu so subsequent `numan use list` and `find_nu_executable_with_root` can resolve the chosen version even when no on-tree install exists (the field uses `#[serde(default, skip_serializing_if = "Option::is_none")]` so the on-disk shape stays `{ "version": ... }` for on-tree selections and pre-existing markers still load). - **Atomic writes**: all JSON state files (lockfile, journal, nu_state/paths.json) use `write_json_atomic` (tempfile in same dir + persist) — no partial-write corruption - **Function signatures**: use `&Path` not `&PathBuf` in function parameters (clippy::ptr_arg is CI-enforced) diff --git a/docs/plans/consolidated-multi-repo-roadmap.md b/docs/plans/consolidated-multi-repo-roadmap.md index 6a49ee7c..4b6880a0 100644 --- a/docs/plans/consolidated-multi-repo-roadmap.md +++ b/docs/plans/consolidated-multi-repo-roadmap.md @@ -294,12 +294,12 @@ Captured from product discussion; subject to change. **Vision:** Numan manages multiple Nu versions simultaneously. The user picks the Nu version that has the plugins they need, and switching is instant. -- `numan use ` — switch active Nu (auto-installs if missing) +- `numan use ` — switch active Nu (errors with a hint to run `setup nu ` if the version is not installed; never auto-downloads) - `numan use latest` — switch to newest installed version - `numan use list` — show installed versions + active marker + per-version plugin counts - Storage: `/tools/nushell//nu` (immutable, one dir per version) -- Active marker: symlink or marker file at `/tools/nushell/active` -- PATH/shim: Numan manages a shim directory on PATH pointing at the active version +- Active marker: JSON file at `/nu_state/active-version.json` (shape `{"version": "X.Y.Z"}`, written atomically by `version_manager::write_active_version`; cleared atomically before removing the versioned tree so the marker cannot dangle at a missing binary) +- PATH/shim: Numan does not manage a shim. PATH persistence is owned by `numan setup nu` (`prepend_process_path` + `persist_user_path`); `numan use` only flips the active marker under the mutation lock with a PreMutation snapshot. **Per-version activation sets:** @@ -324,9 +324,6 @@ yet in the registry. Use this to drive backfill waves once `numan use` ships. Source: awesome-nu + manual discovery. Entries marked `NEEDS_RESEARCH` need their version history filled in before promotion. -**Command reserved:** `numan use` exists as a stub in 0.1.x (prints a -"planned post-1.0" message) so the UX shape is locked. - ### Explicitly NOT in scope for post-1.0 (requires Nu upstream) - Simultaneous multi-version plugin hosting (running 0.113 and 0.114 plugins in diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index cc811227..df8488cd 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -27,13 +27,14 @@ use crate::state::autoload_state::AutoloadState; use crate::state::journal::PendingActivation; use crate::state::lifecycle_journal::PendingLifecycle; use crate::state::lockfile::Lockfile; +use crate::state::migration_journal::{self as migration_journal, PendingMigration}; use crate::state::nupm_import::NupmImportsFile; use crate::state::plugin_deactivate_journal::PendingPluginDeactivate; use crate::util::fs_safety::{acquire_mutation_lock, assert_managed_file_owned}; use crate::util::hints::{ self, active_plugin_mutation_gated_doctor_message, registry_none_fix, setup_nu_use_existing, ACTIVE_PLUGIN_MUTATION_GATED_FIX, CMD_ACTIVATE, CMD_DEACTIVATE, CMD_INIT, CMD_INIT_REFRESH, - CMD_REGISTRY_SYNC, CMD_SETUP_NU, + CMD_REGISTRY_SYNC, CMD_SETUP_NU, CMD_USE, }; const SCHEMA_VERSION: u32 = 1; @@ -615,6 +616,19 @@ fn check_journals(root: &Path, nu_paths: Option<&NuPaths>, findings: &mut Vec records.push(RepairRecord { + id, + status: RepairStatus::Applied, + reason: None, + }), + Err(e) => records.push(RepairRecord { + id, + status: RepairStatus::Failed, + reason: Some(e.to_string()), + }), + } + } + Ok(records) } @@ -1362,6 +1402,7 @@ fn print_report(args: &DoctorArgs, root: &Path, report: &DoctorReport) -> Result "journal.autoload_stale", "journal.lifecycle_pending", "journal.lifecycle_stale", + "journal.migration_pending", ], ), ( @@ -2206,4 +2247,84 @@ mod tests { assert!(json.contains("nu.managed.version")); assert!(json.contains("registry.trust_root")); } + #[test] + fn doctor_reports_migration_journal_finding() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + // Pre-stage a half-applied migration: an empty `/` + // subdir (the reviewer's original bug state) plus a journal at + // `Prepared` recorded by an interrupted `migrate_legacy_install`. + let tools = root.join("tools").join("nushell"); + std::fs::create_dir_all(&tools).unwrap(); + std::fs::create_dir_all(tools.join("0.113.1")).unwrap(); + PendingMigration { + schema_version: crate::state::migration_journal::SCHEMA_VERSION, + version: "0.113.1".to_string(), + stage: crate::state::migration_journal::MigrationStage::Prepared, + } + .save(root) + .unwrap(); + + let report = run_checks_with_options( + &DoctorArgs { + fix: false, + yes: false, + json: false, + nupm_home: None, + }, + root, + &test_doctor_options(), + ) + .unwrap(); + + let f = report + .findings + .iter() + .find(|f| f.id == "journal.migration_pending") + .expect("journal.migration_pending finding"); + assert_eq!(f.severity, Severity::Warn); + assert_eq!(f.repair, RepairTier::Auto); + assert!( + f.message.contains("Prepared") || f.message.contains("prepared"), + "finding must name the journal stage: {}", + f.message + ); + assert!(f.message.contains("0.113.1")); + assert_eq!(f.fix.as_deref(), Some(crate::util::hints::CMD_USE)); + } + + #[test] + fn doctor_fix_reconciles_migration_journal() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + // Same pre-stage as `doctor_reports_migration_journal_finding`. + let tools = root.join("tools").join("nushell"); + std::fs::create_dir_all(&tools).unwrap(); + std::fs::create_dir_all(tools.join("0.113.1")).unwrap(); + PendingMigration { + schema_version: crate::state::migration_journal::SCHEMA_VERSION, + version: "0.113.1".to_string(), + stage: crate::state::migration_journal::MigrationStage::Prepared, + } + .save(root) + .unwrap(); + + let args = DoctorArgs { + fix: true, + yes: true, + json: false, + nupm_home: None, + }; + let _ = execute_with_options(&args, root, test_doctor_options()).unwrap(); + + // After `doctor --fix`: the empty subdir AND the journal must be gone. + assert!( + !tools.join("0.113.1").exists(), + "empty versioned subdir must be removed by reconcile" + ); + assert!( + PendingMigration::load(root).unwrap().is_none(), + "journal must be cleared by reconcile" + ); + } } diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index ad61cc55..7d745c25 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -5,7 +5,9 @@ use std::path::{Path, PathBuf}; use crate::core::platform::Platform; use crate::nu::bootstrap::{self, NuSetupOptions}; -use crate::nu::paths::{find_nu_executable_with_root, find_nu_on_path, probe_nu_config_path}; +use crate::nu::paths::{ + find_nu_executable_with_root, find_nu_on_path, probe_nu_config_path, validate_nushell_binary, +}; use crate::util::atomic::write_bytes_atomic; use crate::util::fs_safety::{ acquire_mutation_lock, assert_managed_file_owned, assert_not_symlink, @@ -166,7 +168,7 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { } if args.use_path { eprintln!("warning: --use-path is deprecated, use 'numan setup nu path' instead"); - return execute_use_path(args.yes, root); + return execute_use_path(args.yes, root, ExecuteUseOpts::default()); } if let Some(existing) = &args.use_existing { eprintln!("warning: --use-existing is deprecated, use 'numan setup nu use ' instead"); @@ -176,12 +178,12 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { Off-PATH registration must persist the binary directory to PATH." ); } - return execute_use_existing(existing, args.yes, root); + return execute_use_existing(existing, args.yes, root, ExecuteUseOpts::default()); } match &args.action { Some(NuAction::Remove) => remove_managed_nu(root, args.yes), - Some(NuAction::Path) => execute_use_path(args.yes, root), + Some(NuAction::Path) => execute_use_path(args.yes, root, ExecuteUseOpts::default()), Some(NuAction::Use { path }) => { if args.skip_path { bail!( @@ -189,7 +191,7 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { Off-PATH registration must persist the binary directory to PATH." ); } - execute_use_existing(path, args.yes, root) + execute_use_existing(path, args.yes, root, ExecuteUseOpts::default()) } None => { // Default: install (latest or pinned version) @@ -198,6 +200,7 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { force: args.force, skip_path: args.skip_path, version: args.version.clone(), + caller_consented_destructive: false, }; let platform = Platform::detect(); bootstrap::execute_nu_setup(root, &platform, &options)?; @@ -206,12 +209,58 @@ pub(crate) fn execute_nu_impl(args: &NuSetupArgs, root: &Path) -> Result<()> { } } -fn execute_use_path(yes: bool, root: &Path) -> Result<()> { +/// Test seam for [`execute_use_existing`] / [`execute_use_path`]. +/// +/// Carry closures that override the production validators so unit tests +/// can mock the binary probe and the destructive-step confirm prompt. +/// Both fields default to `None`, which falls back to the production +/// behavior (`validate_nushell_binary` and +/// `crate::util::confirm::confirm_or_bail`). +#[derive(Default, Copy, Clone)] +pub(crate) struct ExecuteUseOpts<'a> { + /// Override [`validate_nushell_binary`]. Pass `Some(&stub)` from a + /// unit test to skip the real-Nu probe. + #[allow(clippy::type_complexity)] + pub(crate) validate: Option<&'a dyn Fn(&Path) -> Result<()>>, + /// Override [`confirm_or_bail`]. Mirrors `confirm_or_bail`'s + /// signature minus the `yes` flag, which is already bound by the + /// caller. Tests can capture the prompt text (e.g. to assert it + /// contains "no undo") and decline by returning `Err`. + #[allow(clippy::type_complexity)] + pub(crate) confirm: Option<&'a dyn Fn(&str, &str) -> Result<()>>, +} + +/// Validate a user-supplied Nushell binary before destructive operations +/// proceed. The `validate_fn` seam lets unit tests bypass the real Nu +/// probe; production callers should pass `None` to fall through to +/// `validate_nushell_binary` (which runs `nu -c `). +#[allow(clippy::type_complexity)] +fn validate_user_supplied_nu( + path: &Path, + validate_fn: Option<&dyn Fn(&Path) -> Result<()>>, +) -> Result<()> { + let resolved = path + .canonicalize() + .with_context(|| format!("Failed to resolve Nushell binary '{}'", path.display()))?; + match validate_fn { + Some(validator) => validator(&resolved), + None => validate_nushell_binary(&resolved), + } + .with_context(|| format!("'{}' is not a runnable Nushell binary", path.display()))?; + Ok(()) +} + +fn execute_use_path(yes: bool, root: &Path, opts: ExecuteUseOpts<'_>) -> Result<()> { let path_nu = find_nu_on_path()?; println!("Found Nu on PATH: {path_nu}"); + // Validate before any destructive removal: a broken/unrunnable PATH + // Nu must not leave us without a managed install. + validate_user_supplied_nu(Path::new(&path_nu), opts.validate)?; + let managed_dir = bootstrap::managed_nu_dir(root); - if managed_dir.is_dir() { + let managed_dir_was_present = managed_dir.is_dir(); + if managed_dir_was_present { let resolved_path_nu = Path::new(&path_nu) .canonicalize() .with_context(|| format!("Failed to resolve PATH Nu '{}'", path_nu))?; @@ -226,6 +275,32 @@ fn execute_use_path(yes: bool, root: &Path) -> Result<()> { "PATH Nu resolves to the managed install; install a separate Nu or use `setup nu remove`." ); } + + // Consolidate the destructive-removal confirm + the + // register_existing_nu PATH-add prompt into one. Without this + // merge, a user who declines the PATH prompt would already have + // lost their managed install; with it, the user sees one prompt + // covering both. + let nu_parent = resolved_path_nu + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| path_nu.clone()); + let prompt = format!( + "Switching to PATH Nu will remove the managed Nushell install at '{}' \ + (this clears every installed version and the active-version marker; \ + there is no undo) and will add '{}' to your user PATH. Continue?", + managed_dir.display(), + nu_parent, + ); + let cancel_msg = "Switch to PATH Nu cancelled; managed install kept intact."; + // Gate on `!yes` so `confirm_or_bail`'s yes-skip contract holds + // when callers inject a confirm seam for telemetry/audit. + if !yes { + match opts.confirm { + Some(confirm_fn) => confirm_fn(&prompt, cancel_msg)?, + None => confirm_or_bail(&prompt, false, cancel_msg)?, + } + } } remove_managed_nu_if_present(root)?; @@ -234,18 +309,64 @@ fn execute_use_path(yes: bool, root: &Path) -> Result<()> { force: false, skip_path: false, version: None, + // Hoist consent so register_existing_nu's inner PATH prompt is + // suppressed -- only valid because the merged prompt above + // collected consent for both the delete AND the PATH add. + caller_consented_destructive: managed_dir_was_present, }; bootstrap::register_existing_nu(Path::new(&path_nu), &options)?; Ok(()) } -fn execute_use_existing(path: &Path, yes: bool, root: &Path) -> Result<()> { +fn execute_use_existing( + path: &Path, + yes: bool, + root: &Path, + opts: ExecuteUseOpts<'_>, +) -> Result<()> { + // Validate before any destructive removal: an invalid binary must + // not leave us without a managed install. + validate_user_supplied_nu(path, opts.validate)?; + + // Consolidate the destructive-removal confirm + the + // register_existing_nu PATH-add prompt into one (mirrors + // `execute_use_path`'s gate). + let managed_dir = bootstrap::managed_nu_dir(root); + let managed_dir_was_present = managed_dir.is_dir(); + if managed_dir_was_present { + let resolved_path = std::fs::canonicalize(path) + .with_context(|| format!("Failed to resolve Nushell binary '{}'", path.display()))?; + let nu_parent = resolved_path + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| path.display().to_string()); + let prompt = format!( + "Switching to '{}' will remove the managed Nushell install at '{}' \ + (this clears every installed version and the active-version marker; \ + there is no undo) and will add '{}' to your user PATH. Continue?", + path.display(), + managed_dir.display(), + nu_parent, + ); + let cancel_msg = "Switch to existing Nushell cancelled; managed install kept intact."; + if !yes { + match opts.confirm { + Some(confirm_fn) => confirm_fn(&prompt, cancel_msg)?, + None => confirm_or_bail(&prompt, false, cancel_msg)?, + } + } + } + remove_managed_nu_if_present(root)?; let options = NuSetupOptions { yes, force: false, skip_path: false, version: None, + // Hoist consent so register_existing_nu's inner PATH prompt is + // suppressed -- only valid because the merged prompt above + // collected consent for both the delete AND the PATH add. + caller_consented_destructive: managed_dir_was_present, }; bootstrap::register_existing_nu(path, &options)?; Ok(()) @@ -647,4 +768,151 @@ mod tests { execute_nu_impl(&args, &root).unwrap(); assert!(!managed_dir.exists()); } + + // --- direct unit coverage for the new confirm gate in + // `execute_use_existing` --- + + /// Write a fake nu script that returns parseable output for both + /// the Nu probe (`-c