diff --git a/README.md b/README.md index 6ba524f..0d6080d 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,19 @@ to go. ## Quick start ```bash -# Register a prefix alias — `v ` runs `vite `. +# Prefix alias — `v ` runs `vite `. relay add v vite -# Register an exact alias — `vd` always runs `vite dev`, no arguments. +# Prefix alias with default args — `gt ` runs `git clone `. +relay add gt git -p clone + +# Exact alias — `vd` always runs `vite dev`, no arguments accepted. relay add vd vite dev # Use them. v dev # → vite dev v build # → vite build +gt https://example.com/repo.git # → git clone https://example.com/repo.git vd # → vite dev # Inspect. @@ -139,16 +143,26 @@ v build # → vite build v --help # → vite --help ``` +You can also register a prefix alias **with default arguments** using `--prefix` / `-p`. +The default args are always included, and any extra runtime args are appended after them: + +```bash +relay add gt git -p clone +gt https://example.com/repo.git # → git clone https://example.com/repo.git +gt --depth 1 https://example.com/repo.git # → git clone --depth 1 https://example.com/repo.git +``` + ### Exact alias -`relay add ` — the arguments are baked in; runtime args are ignored. +`relay add ` (without `--prefix`) — the arguments are baked in; runtime args are rejected. ```bash relay add vd vite dev vd # → vite dev (always) +vd preview # → error: exact command does not accept extra arguments ``` -Use **prefix** for tools you call with many subcommands (`v`, `g`, `n`). Use **exact** for one-liners you run all the time (`vd`, `gp`, `nci`). +Use **prefix** for tools you call with many subcommands (`v`, `g`, `n`). Use **prefix with args** when the command itself has a fixed subcommand but you want to pass extra flags (`gt` for `git clone`). Use **exact** for one-liners you run all the time with no variation (`vd`, `gp`, `nci`). ### Snippet @@ -177,9 +191,10 @@ relay snippet run goback --dry-run | Command | Description | |---|---| | `relay init` | Create `~/.relay`, write empty config, add `~/.relay/bin` to PATH | -| `relay add [args...]` | Register an alias (prefix if no args, exact otherwise) | +| `relay add [args...]` | Register an alias (prefix if no args or `--prefix`, exact otherwise) | +| `relay add -p [args...]` | Register a prefix alias with default args (runtime args appended) | | `relay remove ` (alias: `rm`) | Delete an alias | -| `relay update [args...]` | Replace an existing alias | +| `relay update [args...]` | Replace an existing alias (accepts `--prefix` / `-p`) | | `relay list` (alias: `ls`) | List all aliases by name | | `relay info ` | Show details for one alias | | `relay clear` (alias: `cls`) | Remove every alias (asks for confirmation) | diff --git a/README.zh.md b/README.zh.md index 316570f..5106af3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -96,12 +96,16 @@ npm 会通过 optionalDependencies 自动下载当前平台的二进制(`linux # Prefix 简写 —— `v <任何参数>` 等价于 `vite <任何参数>` relay add v vite -# Exact 简写 —— `vd` 永远执行 `vite dev`,不接受任何额外参数 +# Prefix 简写带默认参数 —— `gt <参数>` 等价于 `git clone <参数>`,运行时参数会追加 +relay add gt git -p clone + +# Exact 简写 —— `vd` 永远执行 `vite dev`,不接受额外参数 relay add vd vite dev # 使用 v dev # → vite dev v build # → vite build +gt https://xxx/repo.git # → git clone https://xxx/repo.git vd # → vite dev # 查询 @@ -133,16 +137,26 @@ v build # → vite build v --help # → vite --help ``` +你也可以通过 `--prefix` / `-p` 注册**带默认参数**的 Prefix 简写。 +默认参数始终包含在内,运行时输入的额外参数会追加到后面: + +```bash +relay add gt git -p clone +gt https://example.com/repo.git # → git clone https://example.com/repo.git +gt --depth 1 https://example.com/repo.git # → git clone --depth 1 https://example.com/repo.git +``` + ### Exact 简写 -`relay add ` —— 参数被固化,运行时输入的参数会被忽略。 +`relay add `(不加 `--prefix`)—— 参数被固化,返回值输入的任何额外参数都会被拒绝。 ```bash relay add vd vite dev vd # → vite dev(永远) +vd preview # → 报错:exact command does not accept extra arguments ``` -**Prefix** 适合你经常用多个子命令的工具(`v`、`g`、`n`);**Exact** 适合你天天敲的高频组合(`vd`、`gp`、`nci`)。 +**Prefix** 适合你经常用多个子命令的工具(`v`、`g`、`n`);**Prefix 带参数** 适合命令本身包含固定子命令但你仍需要传递额外参数(如 `gt` 对应 `git clone`);**Exact** 适合你天天敲的高频组合(`vd`、`gp`、`nci`)。 ### Snippet(Shell 片段) @@ -171,9 +185,10 @@ relay snippet run goback --dry-run | 命令 | 说明 | |---|---| | `relay init` | 创建 `~/.relay`,写入空 config,并把 `~/.relay/bin` 加入 PATH | -| `relay add [args...]` | 注册简写(无参数为 prefix,有参数为 exact) | +| `relay add [args...]` | 注册简写(无参数或带 `-p` 为 prefix,否则为 exact) | +| `relay add -p [args...]` | 注册带默认参数的 prefix 简写(运行时参数会追加) | | `relay remove `(别名 `rm`) | 删除简写 | -| `relay update [args...]` | 修改已有的简写 | +| `relay update [args...]` | 修改已有的简写(支持 `--prefix` / `-p`) | | `relay list`(别名 `ls`) | 列出所有简写(按名字排序) | | `relay info ` | 查看单个简写的详情 | | `relay clear`(别名 `cls`) | 删除所有简写(会先确认) | diff --git a/src/cli.rs b/src/cli.rs index da03371..3784c9c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -39,7 +39,12 @@ pub enum Command { name: String, /// The target program (e.g. `vite`). program: String, - /// Optional fixed arguments — supplying any makes this an `exact` command. + /// Force prefix mode — extra args are always appended at runtime, + /// even when fixed arguments are supplied. + #[arg(long, short)] + prefix: bool, + /// Optional fixed arguments — supplying any without `--prefix` makes + /// this an `exact` command. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, @@ -52,6 +57,10 @@ pub enum Command { Update { name: String, program: String, + /// Force prefix mode — extra args are always appended at runtime, + /// even when fixed arguments are supplied. + #[arg(long, short)] + prefix: bool, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, @@ -262,14 +271,16 @@ fn dispatch_with_root(command: Command, root: Option) -> Res Command::Add { name, program, + prefix, args, - } => registry::add(&paths, name, program, args)?, + } => registry::add(&paths, name, program, args, *prefix)?, Command::Remove { name } => registry::remove(&paths, name)?, Command::Update { name, program, + prefix, args, - } => registry::update(&paths, name, program, args)?, + } => registry::update(&paths, name, program, args, *prefix)?, Command::List => registry::list(&paths)?, Command::Info { name } => registry::info(&paths, name)?, Command::Discover { program } => discover::run(&paths, program.as_deref())?, diff --git a/src/registry/mod.rs b/src/registry/mod.rs index eae649c..39f3eb1 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -75,7 +75,7 @@ fn is_default_root(paths: &Paths) -> bool { } /// `relay add` — register a new command. -pub fn add(paths: &Paths, name: &str, program: &str, args: &[String]) -> Result<()> { +pub fn add(paths: &Paths, name: &str, program: &str, args: &[String], prefix: bool) -> Result<()> { validate_name(name)?; // validate_program returns the normalized form (e.g. `.exe` stripped). let program = validate_program(program)?; @@ -88,7 +88,7 @@ pub fn add(paths: &Paths, name: &str, program: &str, args: &[String]) -> Result< return Err(RelayError::SnippetNameConflict(name.to_string())); } - let kind = if args.is_empty() { + let kind = if prefix || args.is_empty() { CommandKind::Prefix } else { CommandKind::Exact @@ -156,7 +156,7 @@ pub fn clear(paths: &Paths, auto_yes: bool) -> Result<()> { } /// `relay update` — replace an existing command's program/args. -pub fn update(paths: &Paths, name: &str, program: &str, args: &[String]) -> Result<()> { +pub fn update(paths: &Paths, name: &str, program: &str, args: &[String], prefix: bool) -> Result<()> { let program = validate_program(program)?; let mut config = config::load(paths)?; let entry = config @@ -165,7 +165,7 @@ pub fn update(paths: &Paths, name: &str, program: &str, args: &[String]) -> Resu .ok_or_else(|| RelayError::UnknownCommand(name.to_string()))?; entry.program = program.clone(); entry.args = args.to_vec(); - entry.kind = if args.is_empty() { + entry.kind = if prefix || args.is_empty() { CommandKind::Prefix } else { CommandKind::Exact diff --git a/tests/registry.rs b/tests/registry.rs index 3d749ac..d0bd8da 100644 --- a/tests/registry.rs +++ b/tests/registry.rs @@ -44,7 +44,7 @@ fn add_and_list_prefix_command() { // We can't actually run `which("cargo")` on CI, but cargo is guaranteed // present in the dev environment. Use it as the test program. - registry::add(&paths, "c", "cargo", &[]).unwrap(); + registry::add(&paths, "c", "cargo", &[], false).unwrap(); let cfg = config::load(&paths).unwrap(); let cmd = cfg.commands.get("c").expect("command should exist"); @@ -58,7 +58,7 @@ fn add_exact_command() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "cb", "cargo", &["build".to_string()]).unwrap(); + registry::add(&paths, "cb", "cargo", &["build".to_string()], false).unwrap(); let cfg = config::load(&paths).unwrap(); let cmd = cfg.commands.get("cb").expect("command should exist"); assert_eq!(cmd.kind, CommandKind::Exact); @@ -66,13 +66,27 @@ fn add_exact_command() { assert_eq!(cmd.args, &["build"]); } +#[test] +fn add_prefix_with_args() { + let (_tmp, paths) = tmp_paths(); + registry::init(&paths).unwrap(); + + // --prefix forces Prefix mode even when fixed args are supplied. + registry::add(&paths, "gt", "git", &["clone".to_string()], true).unwrap(); + let cfg = config::load(&paths).unwrap(); + let cmd = cfg.commands.get("gt").expect("command should exist"); + assert_eq!(cmd.kind, CommandKind::Prefix); + assert_eq!(cmd.program, "git"); + assert_eq!(cmd.args, &["clone"]); +} + #[test] fn add_duplicate_name_fails() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "c", "cargo", &[]).unwrap(); + registry::add(&paths, "c", "cargo", &[], false).unwrap(); - let err = registry::add(&paths, "c", "cargo", &[]).unwrap_err(); + let err = registry::add(&paths, "c", "cargo", &[], false).unwrap_err(); assert!(matches!(err, RelayError::CommandExists(_))); } @@ -80,7 +94,7 @@ fn add_duplicate_name_fails() { fn add_empty_name_fails() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - let err = registry::add(&paths, "", "cargo", &[]).unwrap_err(); + let err = registry::add(&paths, "", "cargo", &[], false).unwrap_err(); assert!(matches!(err, RelayError::InvalidCommandName(_, _))); } @@ -88,7 +102,7 @@ fn add_empty_name_fails() { fn add_name_with_special_chars_fails() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - let err = registry::add(&paths, "my command!", "cargo", &[]).unwrap_err(); + let err = registry::add(&paths, "my command!", "cargo", &[], false).unwrap_err(); assert!(matches!(err, RelayError::InvalidCommandName(_, _))); } @@ -97,7 +111,7 @@ fn add_blocklisted_program_fails() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); for bad in &["sh", "bash", "zsh", "cmd", "powershell", "pwsh"] { - let err = registry::add(&paths, "x", bad, &[]).unwrap_err(); + let err = registry::add(&paths, "x", bad, &[], false).unwrap_err(); assert!( matches!(err, RelayError::ForbiddenProgram(_)), "expected forbidden for {bad}" @@ -109,7 +123,7 @@ fn add_blocklisted_program_fails() { fn add_strips_exe_suffix() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "c", "cargo.exe", &[]).unwrap(); + registry::add(&paths, "c", "cargo.exe", &[], false).unwrap(); let cfg = config::load(&paths).unwrap(); assert_eq!(cfg.commands["c"].program, "cargo", ".exe suffix stripped"); } @@ -119,7 +133,7 @@ fn add_rejects_path_in_program() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); for bad in &["./cargo", "/usr/bin/cargo", "foo\\bar.exe"] { - let err = registry::add(&paths, "x", bad, &[]).unwrap_err(); + let err = registry::add(&paths, "x", bad, &[], false).unwrap_err(); assert!( matches!(err, RelayError::InvalidProgram(_, _)), "expected InvalidProgram for {bad}, got {err:?}" @@ -131,7 +145,7 @@ fn add_rejects_path_in_program() { fn remove_existing() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "c", "cargo", &[]).unwrap(); + registry::add(&paths, "c", "cargo", &[], false).unwrap(); registry::remove(&paths, "c").unwrap(); let cfg = config::load(&paths).unwrap(); assert!(cfg.commands.is_empty()); @@ -149,8 +163,8 @@ fn remove_nonexistent_fails() { fn update_existing() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "c", "cargo", &[]).unwrap(); - registry::update(&paths, "c", "cargo", &["test".to_string()]).unwrap(); + registry::add(&paths, "c", "cargo", &[], false).unwrap(); + registry::update(&paths, "c", "cargo", &["test".to_string()], false).unwrap(); let cfg = config::load(&paths).unwrap(); let cmd = cfg.commands.get("c").unwrap(); @@ -158,11 +172,29 @@ fn update_existing() { assert_eq!(cmd.args, &["test"]); } +#[test] +fn update_to_prefix_with_args() { + let (_tmp, paths) = tmp_paths(); + registry::init(&paths).unwrap(); + registry::add(&paths, "gt", "git", &["clone".to_string()], false).unwrap(); + assert_eq!( + config::load(&paths).unwrap().commands["gt"].kind, + CommandKind::Exact + ); + + // Update with --prefix: should flip from Exact to Prefix. + registry::update(&paths, "gt", "git", &["clone".to_string()], true).unwrap(); + assert_eq!( + config::load(&paths).unwrap().commands["gt"].kind, + CommandKind::Prefix + ); +} + #[test] fn update_nonexistent_fails() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - let err = registry::update(&paths, "nope", "cargo", &[]).unwrap_err(); + let err = registry::update(&paths, "nope", "cargo", &[], false).unwrap_err(); assert!(matches!(err, RelayError::UnknownCommand(_))); } @@ -178,7 +210,7 @@ fn list_empty() { fn info_returns_details() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "c", "cargo", &["clippy".to_string()]).unwrap(); + registry::add(&paths, "c", "cargo", &["clippy".to_string()], false).unwrap(); registry::info(&paths, "c").unwrap(); } @@ -220,8 +252,8 @@ fn config_yaml_roundtrip() { fn clear_with_yes_removes_all() { let (_tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "c", "cargo", &[]).unwrap(); - registry::add(&paths, "cb", "cargo", &["build".to_string()]).unwrap(); + registry::add(&paths, "c", "cargo", &[], false).unwrap(); + registry::add(&paths, "cb", "cargo", &["build".to_string()], false).unwrap(); assert_eq!(config::load(&paths).unwrap().commands.len(), 2); registry::clear(&paths, true).unwrap(); @@ -240,7 +272,7 @@ fn clear_on_empty_is_noop() { fn export_appends_yaml_extension_when_missing() { let (tmp, paths) = tmp_paths(); registry::init(&paths).unwrap(); - registry::add(&paths, "c", "cargo", &[]).unwrap(); + registry::add(&paths, "c", "cargo", &[], false).unwrap(); // No extension on the export target — should become `.yaml`. let bare = tmp.path().join("backup"); @@ -271,8 +303,8 @@ fn export_and_import_roundtrip() { registry::init(&paths_b).unwrap(); // Register two commands on machine A. - registry::add(&paths_a, "c", "cargo", &[]).unwrap(); - registry::add(&paths_a, "cb", "cargo", &["build".to_string()]).unwrap(); + registry::add(&paths_a, "c", "cargo", &[], false).unwrap(); + registry::add(&paths_a, "cb", "cargo", &["build".to_string()], false).unwrap(); // Export to a file in tmp_a. let export_file = tmp_a.path().join("export.yaml"); @@ -295,10 +327,10 @@ fn import_default_skips_existing() { registry::init(&paths_b).unwrap(); // A has `c -> cargo`. - registry::add(&paths_a, "c", "cargo", &[]).unwrap(); + registry::add(&paths_a, "c", "cargo", &[], false).unwrap(); // B already has its own `c -> cargo build` — should be kept on import. - registry::add(&paths_b, "c", "cargo", &["build".to_string()]).unwrap(); + registry::add(&paths_b, "c", "cargo", &["build".to_string()], false).unwrap(); let export_file = tmp_a.path().join("export.yaml"); registry::export(&paths_a, Some(&export_file), false).unwrap(); @@ -316,8 +348,8 @@ fn import_overwrite_replaces_existing() { registry::init(&paths_a).unwrap(); registry::init(&paths_b).unwrap(); - registry::add(&paths_a, "c", "cargo", &[]).unwrap(); - registry::add(&paths_b, "c", "cargo", &["build".to_string()]).unwrap(); + registry::add(&paths_a, "c", "cargo", &[], false).unwrap(); + registry::add(&paths_b, "c", "cargo", &["build".to_string()], false).unwrap(); let export_file = tmp_a.path().join("export.yaml"); registry::export(&paths_a, Some(&export_file), false).unwrap();