From 511b84e51e37fd7d5519e4c7268bb6713ba38792 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 13:01:30 +0000 Subject: [PATCH] Polish first-run UX, quiet default logging, scaffold global config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default log level drops to WARN so `anvil env --export | eval` doesn't dump "Loaded N packages" and similar info logs to stderr. Add a global `-v` (info) / `-vv` (debug) flag; `RUST_LOG` still wins. - `anvil list` now prints a hint when no packages are visible, telling the user whether the config is missing, has no `package_paths`, or whether the configured paths don't exist on disk. - New `anvil init --config` scaffolds a commented `~/.anvil.yaml` template so first-time users have something to edit. - `anvil info ` surfaces all available versions when there's more than one on disk — covers the resolver-1.yaml / resolver-2.yaml case where filenames diverge from the package name. - README: install hint about `~/.cargo/bin` on PATH, expanded section on multi-token command aliases, mention of `anvil init --config`, and a note that anvil is quiet by default with `-v` for verbose. https://claude.ai/code/session_0147nsxp1EPFDBeeT3rCyTNc --- README.md | 42 ++++++++++--- src/cli.rs | 19 ++++-- src/config.rs | 36 +++++++++++ src/main.rs | 104 +++++++++++++++++++++++++++++--- tests/cli.rs | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 346 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ee12b15..0fa2b1e 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,19 @@ cargo install anvil-env Or build from source with `cargo build --release` and use `target/release/anvil`. +`cargo install` drops the binary in `~/.cargo/bin/anvil`. Add that directory to +`PATH` so wrappers, hooks, and project scripts can call `anvil` directly +instead of hard-coding the absolute path: + +```bash +# bash / zsh +export PATH="$HOME/.cargo/bin:$PATH" +``` + ## Quick start -Write a package at `~/packages/maya-2024.yaml`: +Run `anvil init --config` to scaffold a starter `~/.anvil.yaml`, then write a +package at `~/packages/maya-2024.yaml`: ```yaml name: maya @@ -132,17 +142,30 @@ overwrite does not slip through. The `commands` map lets `anvil run` pick a program from the package definition. Values expand the same way as `environment` values, and can include baked in -arguments with whitespace or tilde segments: +arguments with whitespace or tilde segments — anvil tokenises the value with +POSIX shell rules, expands `~/` in every token, then runs the first token +with the remaining tokens prepended to whatever the user passes after `--`. ```yaml commands: + # Bare path + maya: ${MAYA_LOCATION}/bin/maya + + # Program + baked-in flags (multi-token alias) nukex: ${NUKE_HOME}/Nuke${VERSION} --nukex + + # Launcher in front of an interpreter (e.g. Python script with a specific runtime) usdview: python3.14 ~/USD/bin/usdview + + # Wrapper that injects defaults; user's `-- ` are appended + hython-debug: ${HFS}/bin/hython -d -v ``` -Anvil tokenises the value with POSIX shell rules, expands `~/` in every token, -then runs the first token with the remaining tokens prepended to whatever the -user passes after `--`. +`anvil run nukex -- --view` therefore exec's +`${NUKE_HOME}/Nuke${VERSION} --nukex --view` — packages can ship sane defaults +for every tool they expose without forcing users to memorise flag soup. Quoted +substrings are preserved as a single argv element, so paths with spaces work +without escaping the whole value. ## Commands @@ -234,12 +257,13 @@ anvil context shell render.ctx.json ### `anvil init` -Scaffold a new package definition. +Scaffold a new package definition, or a starter global config. ```bash anvil init my-tools # my-tools/1.0.0/package.yaml anvil init my-tools --version 2.0 # my-tools/2.0/package.yaml anvil init my-tools --flat # my-tools-1.0.0.yaml +anvil init --config # ~/.anvil.yaml with a commented template ``` ### `anvil completions` @@ -363,7 +387,11 @@ filters: |---|---| | `ANVIL_CONFIG` | override config file location | | `ANVIL_PACKAGES` | additional package paths, colon separated | -| `RUST_LOG` | log verbosity, e.g. `RUST_LOG=debug` | +| `RUST_LOG` | log verbosity, e.g. `RUST_LOG=debug` (overrides `-v`) | + +By default anvil only logs warnings and errors so it can be piped safely +(`eval "$(anvil env maya-2024 --export)"`). Pass `-v` for info-level diagnostics +or `-vv` for debug. If no config file is found, anvil falls back to `$ANVIL_PACKAGES`, `$HOME/packages`, `$HOME/.local/share/anvil/packages`, and `/opt/packages`. diff --git a/src/cli.rs b/src/cli.rs index 9062521..23620df 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -12,6 +12,11 @@ pub struct Cli { #[arg(long, global = true)] pub refresh: bool, + /// Increase log verbosity: `-v` enables info, `-vv` enables debug. + /// `RUST_LOG` overrides this when set. + #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)] + pub verbose: u8, + #[command(subcommand)] pub command: Commands, } @@ -107,18 +112,22 @@ pub enum Commands { action: ContextAction, }, - /// Scaffold a new package definition + /// Scaffold a new package definition (or `--config` for the global config) Init { - /// Package name (e.g., my-tools) - name: String, + /// Package name (e.g., my-tools). Omit when using `--config`. + name: Option, /// Package version (default: 1.0.0) - #[arg(short, long, default_value = "1.0.0")] + #[arg(long, default_value = "1.0.0")] version: String, /// Create as a flat YAML file instead of a nested directory - #[arg(long)] + #[arg(long, conflicts_with = "config")] flat: bool, + + /// Scaffold a global `~/.anvil.yaml` instead of a package + #[arg(long, conflicts_with_all = ["flat", "name"])] + config: bool, }, /// Generate shell completions diff --git a/src/config.rs b/src/config.rs index 50d2a5c..0624294 100644 --- a/src/config.rs +++ b/src/config.rs @@ -189,6 +189,42 @@ impl Config { Ok(config) } + /// Diagnose why no packages were found and return a hint suitable for + /// printing to stderr. Returns `None` when at least one package path + /// exists on disk (i.e. there's no obvious config problem to surface). + pub fn first_run_hint(&self) -> Option { + let config_path = Self::config_path(); + if !config_path.exists() { + return Some(format!( + "No anvil config found at {}.\n - Run `anvil init --config` to scaffold one\n - Or set ANVIL_PACKAGES to a colon-separated list of package directories", + config_path.display() + )); + } + + if self.package_paths.is_empty() { + return Some(format!( + "{} sets no `package_paths`.\n - Add e.g. `package_paths: [~/packages]` to point anvil at your packages\n - Or set ANVIL_PACKAGES", + config_path.display() + )); + } + + if self.all_package_paths().is_empty() { + let listed = self + .package_paths + .iter() + .map(|p| format!(" - {}", p)) + .collect::>() + .join("\n"); + return Some(format!( + "None of the configured package_paths exist on disk:\n{}\n Create one of them, or edit {}.", + listed, + config_path.display() + )); + } + + None + } + /// Get config file path pub fn config_path() -> PathBuf { if let Ok(path) = std::env::var("ANVIL_CONFIG") { diff --git a/src/main.rs b/src/main.rs index 07a74b1..2597fe5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,17 +20,28 @@ use context::{ContextPackage, Lockfile, SavedContext}; use resolver::Resolver; fn main() -> Result<()> { - // Initialize logging + let cli = Cli::parse(); + + // Default to WARN so casual `anvil env ` invocations don't litter + // stderr with "Loaded N packages" and similar. `-v` / `-vv` step up + // to info / debug; `RUST_LOG` still wins when set. + let default_filter = match cli.verbose { + 0 => "anvil=warn", + 1 => "anvil=info", + _ => "anvil=debug", + }; tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "anvil=info".into()), + .unwrap_or_else(|_| default_filter.into()), + ) + .with( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_writer(std::io::stderr), ) - .with(tracing_subscriber::fmt::layer().with_target(false)) .init(); - let cli = Cli::parse(); - // Load config let config = Config::load()?; let refresh = cli.refresh; @@ -71,8 +82,17 @@ fn main() -> Result<()> { cmd_context_shell(&config, &file, shell)?; } }, - Commands::Init { name, version, flat } => { - cmd_init(&name, &version, flat)?; + Commands::Init { name, version, flat, config: scaffold_config } => { + if scaffold_config { + cmd_init_config()?; + } else { + let name = name.ok_or_else(|| { + anyhow::anyhow!( + "anvil init: provide a package name, or pass --config to scaffold ~/.anvil.yaml" + ) + })?; + cmd_init(&name, &version, flat)?; + } } Commands::Completions { shell } => { Cli::print_completions(shell); @@ -240,6 +260,21 @@ fn cmd_list(config: &Config, package: Option, refresh: bool) -> Result<( } else { // List all packages let packages = resolver.list_packages()?; + if packages.is_empty() { + if let Some(hint) = config.first_run_hint() { + eprintln!("No packages found.\n {}", hint.replace('\n', "\n ")); + } else { + eprintln!( + "No packages found in any of the configured paths:\n{}", + config + .all_package_paths() + .iter() + .map(|p| format!(" - {}", p.display())) + .collect::>() + .join("\n") + ); + } + } for pkg in packages { println!("{}", pkg); } @@ -255,6 +290,15 @@ fn cmd_info(config: &Config, package: &str, refresh: bool) -> Result<()> { println!("Name: {}", pkg.name); println!("Version: {}", pkg.version); + // When the user asked for a bare name (e.g. `anvil info resolver`) and + // there are several versions on disk, surface them so the asymmetry + // between filename (`resolver-1.yaml`) and package name (`resolver`) + // doesn't hide the others. + if let Ok(versions) = resolver.list_versions(&pkg.name) { + if versions.len() > 1 { + println!("Available versions: {}", versions.join(", ")); + } + } if let Some(desc) = &pkg.description { println!("Description: {}", desc); } @@ -524,6 +568,52 @@ environment: Ok(()) } +/// Scaffold a global `~/.anvil.yaml` so first-time users have something to +/// edit instead of an empty file. +fn cmd_init_config() -> Result<()> { + let path = Config::config_path(); + if path.exists() { + anyhow::bail!("{} already exists", path.display()); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + + let template = r#"# Anvil global config — see https://github.com/voidreamer/anvil + +# Where to look for package definitions, in priority order. +# Each entry can be a directory of flat `-.yaml` files +# and/or nested `//package.yaml` packages. +package_paths: + - ~/packages + # - /studio/packages + # - ${STUDIO_ROOT}/packages + +# Optional: package set aliases (use as `anvil run -- ...`). +# aliases: +# maya-anim: +# - maya-2024 +# - studio-tools + +# Optional: shell that `anvil shell` uses by default. +# default_shell: zsh + +# Optional: hide / restrict packages by glob. +# filters: +# include: ["maya-*", "houdini-*"] +# exclude: ["*-dev"] +"#; + + std::fs::write(&path, template) + .with_context(|| format!("Failed to write {}", path.display()))?; + println!("Created {}", path.display()); + println!("Edit it to point `package_paths` at your package directory, then run `anvil list`."); + + Ok(()) +} + // --------------------------------------------------------------------------- // Wrap // --------------------------------------------------------------------------- diff --git a/tests/cli.rs b/tests/cli.rs index 92f5f76..39a8d73 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -879,6 +879,170 @@ fn publish_refuses_overwrite() { .failure(); } +// ---- first-run hints / init --config ---- + +#[test] +fn list_emits_hint_when_no_packages_found() { + // Config exists but points at an empty directory: list should emit a + // hint to stderr instead of staying silent. + let dir = TempDir::new().unwrap(); + let empty_pkg_dir = dir.path().join("empty-packages"); + fs::create_dir_all(&empty_pkg_dir).unwrap(); + let cfg_path = dir.path().join("config.yaml"); + fs::write( + &cfg_path, + format!("package_paths:\n - {}\n", empty_pkg_dir.display()), + ) + .unwrap(); + + anvil(cfg_path.to_str().unwrap()) + .args(["list"]) + .assert() + .success() + .stderr(predicate::str::contains("No packages found")); +} + +#[test] +fn list_hint_when_package_paths_missing() { + // Config has package_paths entries but none exist on disk. + let dir = TempDir::new().unwrap(); + let cfg_path = dir.path().join("config.yaml"); + fs::write( + &cfg_path, + "package_paths:\n - /nonexistent/anvil-test/aaa\n - /nonexistent/anvil-test/bbb\n", + ) + .unwrap(); + + anvil(cfg_path.to_str().unwrap()) + .args(["list"]) + .assert() + .success() + .stderr(predicate::str::contains("None of the configured package_paths exist")); +} + +#[test] +fn init_config_scaffolds_global_yaml() { + // anvil init --config writes ~/.anvil.yaml when none exists. + // We override HOME so the test never touches the real one. + let dir = TempDir::new().unwrap(); + let fake_home = dir.path().join("home"); + fs::create_dir_all(&fake_home).unwrap(); + + Command::cargo_bin("anvil") + .unwrap() + .env("HOME", &fake_home) + .env("RUST_LOG", "anvil=error") + .env_remove("ANVIL_CONFIG") + .args(["init", "--config"]) + .assert() + .success() + .stdout(predicate::str::contains(".anvil.yaml")); + + let cfg = fake_home.join(".anvil.yaml"); + assert!(cfg.exists()); + let content = fs::read_to_string(&cfg).unwrap(); + assert!(content.contains("package_paths:")); + assert!(content.contains("~/packages")); +} + +#[test] +fn init_config_refuses_overwrite() { + let dir = TempDir::new().unwrap(); + let fake_home = dir.path().join("home"); + fs::create_dir_all(&fake_home).unwrap(); + fs::write(fake_home.join(".anvil.yaml"), "package_paths: []\n").unwrap(); + + Command::cargo_bin("anvil") + .unwrap() + .env("HOME", &fake_home) + .env("RUST_LOG", "anvil=error") + .env_remove("ANVIL_CONFIG") + .args(["init", "--config"]) + .assert() + .failure(); +} + +#[test] +fn init_without_name_or_config_errors() { + let dir = TempDir::new().unwrap(); + Command::cargo_bin("anvil") + .unwrap() + .env("RUST_LOG", "anvil=error") + .current_dir(dir.path()) + .args(["init"]) + .assert() + .failure(); +} + +// ---- anvil info: multiple versions ---- + +#[test] +fn info_lists_other_versions_when_multiple_exist() { + // When several files share a name (`resolver-1.yaml`, `resolver-2.yaml`), + // `anvil info resolver` should call out all candidate versions instead + // of silently picking the highest. + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("resolver-1.yaml"), + "name: resolver\nversion: \"1\"\n", + ) + .unwrap(); + fs::write( + pkg_dir.join("resolver-2.yaml"), + "name: resolver\nversion: \"2\"\n", + ) + .unwrap(); + fs::write( + pkg_dir.join("resolver-3.yaml"), + "name: resolver\nversion: \"3\"\n", + ) + .unwrap(); + let cfg_path = dir.path().join("config.yaml"); + fs::write( + &cfg_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + + anvil(cfg_path.to_str().unwrap()) + .args(["info", "resolver"]) + .assert() + .success() + .stdout(predicate::str::contains("Available versions:")) + .stdout(predicate::str::contains("1")) + .stdout(predicate::str::contains("2")) + .stdout(predicate::str::contains("3")); +} + +// ---- verbose flag ---- + +#[test] +fn default_log_level_is_quiet() { + // No RUST_LOG, no -v: info-level "Loaded N packages" should not appear. + let (_dir, cfg_str) = setup_env(); + let mut cmd = Command::cargo_bin("anvil").unwrap(); + cmd.env("ANVIL_CONFIG", &cfg_str); + cmd.env_remove("RUST_LOG"); + cmd.args(["list"]) + .assert() + .success() + .stderr(predicate::str::contains("Loaded").not()); +} + +#[test] +fn verbose_flag_enables_info_logs() { + let (_dir, cfg_str) = setup_env(); + let mut cmd = Command::cargo_bin("anvil").unwrap(); + cmd.env("ANVIL_CONFIG", &cfg_str); + cmd.env_remove("RUST_LOG"); + cmd.args(["-v", "list"]) + .assert() + .success() + .stderr(predicate::str::contains("Loaded")); +} + // ---- anvil shell flags ---- #[test]