Skip to content

feat: alias variables with {{name}} placeholders#121

Open
sassman wants to merge 24 commits intomainfrom
feat/vars
Open

feat: alias variables with {{name}} placeholders#121
sassman wants to merge 24 commits intomainfrom
feat/vars

Conversation

@sassman
Copy link
Copy Markdown
Owner

@sassman sassman commented May 2, 2026

Closes #103.

What

Named variables that substitute into alias commands as {{name}}. Set once with am var set, scoped local / profile / global. Variables resolve scope-locally — an alias only sees vars defined at its own scope.

am var set -l opts "-C opt-level=3"
am add -l r 'RUSTFLAGS="{{opts}}" cargo run --release'

Why

Recurring command fragments (compiler flags, paths, version numbers) were duplicated across aliases. Vars factor them out without inventing a new alias-of-aliases pattern. Set once, edit one place, sync rebuilds everything.

Working forms of the example alias

The same alias body can be expressed several shell-quoting ways. All of these end up with the alias body RUSTFLAGS="{{opts}}" cargo run --release --bin am (or the '-variant) and produce a working fish function after am sync:

# A. outer ' , inner "  — most readable
am add -l r 'RUSTFLAGS="{{opts}}" cargo run --release --bin am'

# B. outer " , inner '
am add -l r "RUSTFLAGS='{{opts}}' cargo run --release --bin am"

# C. outer " , escaped inner \"
am add -l r "RUSTFLAGS=\"{{opts}}\" cargo run --release --bin am"

# D. no outer wrap (clap's trailing_var_arg + shell escaping)
am add -l r RUSTFLAGS=\"{{opts}}\" cargo run --release --bin am

A bare-unquoted {{opts}} parses but breaks at runtime if the value contains whitespace — that's expected shell semantics, the user picks the quoting:

# E. won't run if the value has spaces
am add -l r 'RUSTFLAGS={{opts}} cargo run --release --bin am'

Behavioural notes

  • Vars are literal text, not shell-expanded — '{{opts}}' substitutes to '-C opt-level=3' (one token), not ''-C opt-level=3'' (two empty strings + bare flag).
  • Aliases referencing an undefined var emit a warning and are skipped from the sync, not silently broken.
  • am var set/unset triggers a sync via the shell wrappers, so dependent aliases come alive immediately.
  • am var set <name> <value> accepts hyphen-leading values like -C opt-level=3 (clap allow_hyphen_values).

Side-effect cleanups landed in this PR

  • Bin's local-mutation helpers (add_local_alias etc.) now go through a shared upsert_local_aliases / mutate_existing_local pair — six near-clones collapse to ~5 lines each. Fixes a dead-code identity branch and an inconsistent missing parent-prompt.
  • Bin's local writes now refresh the security trust hash via AppModel::refresh_project_trust_at, matching the lib path used by am-tui and tests. am add -l ... no longer leaves the hash stale.
  • All scope-flagged commands (add / remove / var) share one TargetScopeArgs clap struct.
  • All alias / subcommand / var handlers in update.rs now go through resolve_target + get_profile_mut — ~130 lines of repeated dispatch ladders gone.
  • Fish's complete --wraps target skips KEY=value env-var prefixes (with quote-aware values) — fixes the parse-error cascade where RUSTFLAGS="-C opt-level=3" cargo run produced complete -c r --wraps "RUSTFLAGS="-C".
  • update::InitShell now routes its output through Effect::Print instead of an inline print!, one step closer to a fully pure update().

sassman added 24 commits May 2, 2026 20:32
The alias and subcommand handlers each duplicated:
- the project-trust check (12+ inline copies)
- the ActiveProfile-empty-session fallback ladder (6 inline copies)

Both helpers (resolve_target, require_project_trust, get_profile_mut) already
existed for the var handlers — applying them across the board collapses ~130
lines of repeated dispatch into the same three-arm match every handler now uses.

Also aligns resolve_target's ActiveProfile resolution with the existing
resolve_profile_mut behaviour (.last() — most recently activated profile,
highest precedence in the engine merge). This fixes a latent inconsistency
where am var defaulted to active_profiles[0] while am alias used .last().
…s in bin

Six near-identical local-mutation helpers (add/remove × alias/subcommand/var)
each reimplemented the same find-or-prompt-or-create dance. Two closure-taking
helpers now own the shape; each helper collapses to ~5 lines.

Side-effect fixes:
- add_local_var was missing the "found existing .aliases at <parent>, add
  there?" prompt that alias/subcommand had — now consistent.
- add_local_var had a dead-code identity branch (both arms returning
  local_path) — gone.
Two divergent paths used to mutate `.aliases`:
- lib's execute_effect (used by am-tui and tests) → save_project_with →
  full ritual (write file + recompute hash + update security_config + save).
- bin's execute_effects (used by the main binary) → standalone helpers →
  just write file. No hash, no security update.

Result: `am add -l foo bar` left the security hash stale, so the next read
saw the file as Tampered. The TUI doing the same operation didn't.

Fix: extract refresh_project_trust_at on AppModel (the post-save bookkeeping
half of save_project_with), and have the bin's interactive helpers call it
after their own write. save_project_with now delegates to it too — single
source of truth for "the file changed, trust must follow."

The bin keeps its interactive parent-prompt logic (which the lib path can't
support since it has no stdin/stdout access), but the security accounting
is now consistent across both paths.
`am var set -l opts "-C opt-level=3"` rejected the value because clap parsed
`-C` as an unknown flag. Set `allow_hyphen_values = true` on the value arg —
values are opaque strings and shouldn't be parsed as flags.

Adds a regression test pinning the parse behaviour.
The fish/bash/zsh/powershell am-wrappers re-source sync output after
mutations like add/remove/profile-use, but vars were missing from the
case lists. Result: changing a var didn't update aliases that referenced
it until the next cd hook (or a manual `am sync`) — defeating the whole
"set once and forget" UX of vars.

Add `var v` (with subactions `set`/`unset`) to all four wrappers.
The init snapshots regenerate accordingly.
Instead of `print!` inside the InitShell handler, return the rendered
init script as `Effect::Print(output)`. Brings InitShell in line with
GetVar/ListVars and the rest of the effects pattern, and is one step
closer to a fully pure update().
Var substitution was reusing the quote-aware walker designed for
positional args (\`{{1}}\`/\`{{@}}\`), which breaks out of single-quoted
regions so shell variable references can expand. That's wrong for vars:
their values are literal text, not expansions.

For \`RUSTFLAGS='{{opts}}' cargo run\` with opts="-C opt-level=3", the
walker emitted \`RUSTFLAGS=''-C opt-level=3''\` — two empty single-quoted
strings sandwiching unquoted \`-C opt-level=3\`. Fish then parsed \`-C\`
as a command and failed.

Switch to a plain regex replacement. The user's quotes wrap the value
as written: \`'{{opts}}'\` becomes \`'-C opt-level=3'\`, which is a
single token. Same logic for double-quoted and unquoted forms.

Documented limitation: a value containing the same quote char the user
wrapped \`{{name}}\` with will produce broken shell — the user picks
the quoting, the value is opaque text.

Removes the now-unused \`substitute_quote_aware_for_vars\` shim too.
The --wraps target was computed via cmd.split_whitespace().next(), which
splits on spaces blindly. For an alias body like
\`RUSTFLAGS="-C opt-level=3" cargo run --release --bin am\` the first
"word" became \`RUSTFLAGS="-C\` — an unbalanced double quote — and ended
up in the emitted line as

    complete -c r --wraps "RUSTFLAGS="-C"

Fish then accumulated unclosed-string state across every later line of
sync output, eventually erroring with "Expected a command, but found an
incomplete token" on whatever line tipped it past EOL.

Replace the raw split with first_command_word() that skips leading
\`KEY=value\` env-var prefixes (with quote-aware values) and refuses
candidates containing quote characters. The result for the offending
case is now \`complete -c r --wraps "cargo"\` — both correct and useful
(autocompletion follows the actual program). Aliases that genuinely
start with a quoted token simply skip the --wraps line rather than
emit broken fish.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: variables for aliases

1 participant