Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ relay discover vite # aliases grouped by target program
relay snippet add goback "cd ../"
relay snippet run goback --dry-run

# Snippets support {{0}} {{1}} … placeholders — pass args at runtime.
relay snippet add killport --shell powershell "Get-NetTCPConnection -LocalPort {{0}} ^| ForEach-Object { Stop-Process -Id `$_.OwningProcess -Force }"
relay snippet run killport --dry-run 4600
# → Get-NetTCPConnection -LocalPort 4600 | ...

# Diagnose.
relay doctor # check PATH, shims, config
relay doctor --fix # auto-repair missing shims and PATH entries
Expand Down Expand Up @@ -180,6 +185,17 @@ relay snippet run goback
relay snippet run goback --dry-run
```

**Placeholders:** Use `{{0}}` `{{1}}` … in snippet content and pass arguments at runtime:

```bash
relay snippet add greet --shell powershell "Write-Host Hello {{0}}"
relay snippet run greet --dry-run World # → Write-Host Hello World
relay snippet add killport --shell powershell "Get-NetTCPConnection -LocalPort {{0}} ^| ForEach-Object { Stop-Process -Id `$_.OwningProcess -Force }"
killport 4600 # shim forwarding works too
```

If a placeholder index exceeds the number of arguments provided, relay returns a clear error.

**Why snippets?** Commands like `cd`, `export`, complex pipes, and shell built-ins can't work through relay's direct-execution model. Snippets fill that gap while keeping cross-shell portability.

---
Expand Down Expand Up @@ -239,9 +255,9 @@ relay snippet run goback --dry-run
| `relay snippet edit <name> --content <c>` | Update a snippet's content |
| `relay snippet edit <name> --desc <d>` | Update description (pass `""` to clear) |
| `relay snippet edit <name> --shell <d>` | Change the shell dialect |
| `relay snippet run <name>` | Execute a snippet (auto-translates to current shell) |
| `relay snippet run <name> --dry-run` | Print the translated command without executing |
| `relay snippet run <name> --no-translate` | Run as-is, skip cross-shell translation |
| `relay snippet run <name> [args...]` | Execute a snippet (auto-translates to current shell). `args` replace `{{0}}` `{{1}}` … placeholders |
| `relay snippet run <name> --dry-run [args...]` | Print the substituted & translated command without executing |
| `relay snippet run <name> --no-translate [args...]` | Run as-is, skip cross-shell translation (placeholders still apply) |
| `relay snippet clear` | Remove all snippets (asks for confirmation) |
| `relay snippet clear --yes` | Same, no confirmation |

Expand Down
22 changes: 19 additions & 3 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ relay discover vite # 按目标程序聚合查看
relay snippet add goback "cd ../"
relay snippet run goback --dry-run

# Snippet 支持 {{0}} {{1}} … 占位符,运行时传入参数自动替换
relay snippet add killport --shell powershell "Get-NetTCPConnection -LocalPort {{0}} ^| ForEach-Object { Stop-Process -Id `$_.OwningProcess -Force }"
relay snippet run killport --dry-run 4600
# → Get-NetTCPConnection -LocalPort 4600 | ...

# 诊断
relay doctor # 检查 PATH / shim / 配置
relay doctor --fix # 自动修复缺失的 shim 和 PATH
Expand Down Expand Up @@ -174,6 +179,17 @@ relay snippet run goback
relay snippet run goback --dry-run
```

**占位符:** Snippet 内容中可用 `{{0}}` `{{1}}` … 占位符,运行时传入的参数会依次替换:

```bash
relay snippet add greet --shell powershell "Write-Host Hello {{0}}"
relay snippet run greet --dry-run World # → Write-Host Hello World
relay snippet add killport --shell powershell "Get-NetTCPConnection -LocalPort {{0}} ^| ForEach-Object { Stop-Process -Id `$_.OwningProcess -Force }"
killport 4600 # 通过 shim 传参同样有效
```

如果占位符索引超出传入参数数量,relay 会报错并提示缺少哪个参数。

**为什么需要 snippet?** `cd`、`export`、复杂管道、Shell 内置命令等无法通过 relay 的直接执行模型工作。Snippet 填补了这一空白,同时保持跨 Shell 的移植性。

---
Expand Down Expand Up @@ -233,9 +249,9 @@ relay snippet run goback --dry-run
| `relay snippet edit <name> --content <c>` | 修改内容 |
| `relay snippet edit <name> --desc <d>` | 修改描述(传 `""` 清除) |
| `relay snippet edit <name> --shell <d>` | 修改 Shell 方言 |
| `relay snippet run <name>` | 执行 snippet(自动翻译到当前 Shell) |
| `relay snippet run <name> --dry-run` | 只打印翻译后的命令,不执行 |
| `relay snippet run <name> --no-translate` | 跳过翻译,原样执行 |
| `relay snippet run <name> [args...]` | 执行 snippet(自动翻译到当前 Shell)。`args` 按序替换 `{{0}}` `{{1}}` … 占位符 |
| `relay snippet run <name> --dry-run [args...]` | 只打印替换和翻译后的命令,不执行 |
| `relay snippet run <name> --no-translate [args...]` | 跳过翻译,原样执行(仍支持占位符替换) |
| `relay snippet clear` | 删除所有 snippets(会先确认) |
| `relay snippet clear --yes` | 同上,但不确认 |

Expand Down
11 changes: 10 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ pub enum SnippetAction {
shell: Option<String>,
},
/// Execute a snippet, translating it to the current shell if needed.
///
/// Use `{{0}}`, `{{1}}`, … placeholders in the snippet content and pass
/// trailing arguments to substitute them at runtime:
/// relay snippet run killport 4000
Run {
name: String,
/// Print the translated command without executing it.
Expand All @@ -238,6 +242,10 @@ pub enum SnippetAction {
/// Overrides auto-detection — useful for testing cross-shell results.
#[arg(long)]
target: Option<String>,
/// Arguments to substitute for `{{0}}`, `{{1}}`, … placeholders in
/// the snippet content.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Remove all snippets.
Clear {
Expand Down Expand Up @@ -330,7 +338,8 @@ fn dispatch_with_root(command: Command, root: Option<std::path::PathBuf>) -> Res
no_translate,
force,
target,
} => snippet::run(&paths, name, *dry_run, *no_translate, target.as_deref(), *force)?,
args,
} => snippet::run(&paths, name, *dry_run, *no_translate, target.as_deref(), *force, args)?,
SnippetAction::Clear { yes } => snippet::clear(&paths, *yes)?,
},
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,7 @@ pub enum RelayError {

#[error("snippet translation failed: {0}")]
SnippetTranslateError(String),

#[error("placeholder {{{{ {0} }}}} has no corresponding argument (only {1} arg(s) provided)")]
MissingPlaceholderArg(usize, usize),
}
64 changes: 59 additions & 5 deletions src/snippet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,17 @@ pub fn clear(paths: &Paths, auto_yes: bool) -> Result<()> {

// ─── Run (cross-shell execution) ─────────────────────────────────────────

/// `relay snippet run <name>` — execute a snippet, optionally translating
/// it to the current shell dialect via polysh.
/// `relay snippet run <name> [args...]` — execute a snippet, optionally
/// translating it to the current shell dialect via polysh.
///
/// Placeholders `{{0}}`, `{{1}}`, … in the snippet content are substituted
/// with the corresponding positional arguments before execution.
///
/// Pass `target` to override the auto-detected current dialect — useful
/// for testing cross-shell translation results (e.g. `--target unix`).
/// Pass `force` to run a best-effort translation even when polysh cannot
/// fully translate all segments.
pub fn run(paths: &Paths, name: &str, dry_run: bool, no_translate: bool, target: Option<&str>, force: bool) -> Result<()> {
pub fn run(paths: &Paths, name: &str, dry_run: bool, no_translate: bool, target: Option<&str>, force: bool, args: &[String]) -> Result<()> {
let config = config::load(paths)?;
let snip = config
.snippets
Expand All @@ -215,10 +218,13 @@ pub fn run(paths: &Paths, name: &str, dry_run: bool, no_translate: bool, target:
};
let stored_dialect = snip.shell;

// Substitute {{0}}, {{1}}, … placeholders with positional args.
let content = substitute_placeholders(&snip.content, args)?;

let command = if no_translate || current_dialect == stored_dialect {
snip.content.clone()
content
} else {
translate_content(&snip.content, stored_dialect, current_dialect, force)?
translate_content(&content, stored_dialect, current_dialect, force)?
};

if dry_run {
Expand Down Expand Up @@ -252,6 +258,54 @@ fn validate_name(name: &str) -> Result<()> {
Ok(())
}

/// Substitute `{{0}}`, `{{1}}`, … placeholders in `content` with the
/// corresponding values from `args`.
///
/// Returns the original content unchanged when there are no `{{…}}` markers.
/// Non-numeric placeholders (e.g. `{{name}}`) are left as-is. Unclosed `{{`
/// is also left as-is. If a numeric placeholder index exceeds `args.len()`
/// an [`RelayError::MissingPlaceholderArg`] error is returned.
fn substitute_placeholders(content: &str, args: &[String]) -> Result<String> {
// Fast path: no placeholder markers at all.
if !content.contains("{{") {
return Ok(content.to_string());
}

let mut result = String::with_capacity(content.len());
let mut rest = content;

while let Some(start) = rest.find("{{") {
result.push_str(&rest[..start]);
rest = &rest[start + 2..];

if let Some(end) = rest.find("}}") {
let idx_str = rest[..end].trim();
match idx_str.parse::<usize>() {
Ok(idx) => {
if idx >= args.len() {
return Err(RelayError::MissingPlaceholderArg(idx, args.len()));
}
result.push_str(&args[idx]);
}
Err(_) => {
// Not a number — leave the marker as-is so users can have
// literal `{{name}}` in their snippets when needed.
result.push_str("{{");
result.push_str(&rest[..=end]);
}
}
rest = &rest[end + 2..];
} else {
// Unclosed `{{` — leave it as-is.
result.push_str("{{");
result.push_str(rest);
rest = "";
}
}
result.push_str(rest);
Ok(result)
}

/// Detect the current shell dialect via polysh.
pub fn detect_current_dialect() -> ShellDialect {
let info = polysh::detector::detect_shell();
Expand Down