claude-aws-mfa 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,28 +8,36 @@ on:
8
8
  jobs:
9
9
  detect-release:
10
10
  runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
11
13
  outputs:
12
14
  version: ${{ steps.parse.outputs.version }}
13
15
  prerelease: ${{ steps.parse.outputs.prerelease }}
14
16
  steps:
15
17
  - uses: actions/checkout@v4
16
- with:
17
- fetch-tags: true
18
18
 
19
19
  - name: Parse latest changelog version
20
20
  id: parse
21
+ env:
22
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21
23
  run: |
22
- # Extract the first heading that matches: ## [vX.Y.Z...] - YYYY-MM-DD
23
- # The date is required — headings without a date are treated as drafts.
24
- HEADING=$(grep -P '^## \[v[^\]]+\]\s+-\s+\d{4}-\d{2}-\d{2}' CHANGELOG.md | head -1)
24
+ # The topmost `## [...]` heading governs, dated or not.
25
+ #
26
+ # Deliberately NOT "the topmost *dated* heading": that made a typo in
27
+ # the newest entry's date invisible. The heading simply failed to
28
+ # match, the search fell through to an older entry — or to nothing —
29
+ # and the run reported success having done nothing at all. A heading
30
+ # that was meant to release has to fail loudly, not silently.
31
+ HEADING=$(grep -m1 -P '^## \[' CHANGELOG.md || true)
25
32
 
26
33
  if [ -z "$HEADING" ]; then
27
- echo "No dated release heading found — nothing to release."
34
+ echo "No version headings in CHANGELOG.md — nothing to release."
28
35
  echo "version=" >> "$GITHUB_OUTPUT"
29
36
  exit 0
30
37
  fi
31
38
 
32
39
  VERSION=$(echo "$HEADING" | grep -oP '(?<=\[)[^\]]+')
40
+ REST=$(echo "$HEADING" | sed -E 's/^## \[[^]]*\]//') # whatever follows the version
33
41
 
34
42
  # Must be valid semver with v prefix
35
43
  if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then
@@ -37,24 +45,56 @@ jobs:
37
45
  exit 1
38
46
  fi
39
47
 
40
- # Verify the date is today (UTC +/- 12h to accommodate any timezone)
41
- ENTRY_DATE=$(echo "$HEADING" | grep -oP '\d{4}-\d{2}-\d{2}')
42
- YESTERDAY=$(date -u -d '-12 hours' +%Y-%m-%d)
43
- TODAY=$(date -u +%Y-%m-%d)
44
- TOMORROW=$(date -u -d '+12 hours' +%Y-%m-%d)
45
- if [[ "$ENTRY_DATE" != "$YESTERDAY" && "$ENTRY_DATE" != "$TODAY" && "$ENTRY_DATE" != "$TOMORROW" ]]; then
46
- echo "::error::Changelog date $ENTRY_DATE does not match today — expected $YESTERDAY, $TODAY, or $TOMORROW"
47
- exit 1
48
+ # Has this already shipped? This check has to come BEFORE any
49
+ # validation of the entry, because a released entry keeps sitting at
50
+ # the top of the changelog: every later push to main re-parses it.
51
+ # Validating first rejects an already-published entry as a malformed
52
+ # new release — that's what used to turn main red after every release.
53
+ #
54
+ # Ask the remote rather than the local clone: checkout doesn't
55
+ # reliably bring tags down, so a missing local tag proves nothing.
56
+ if [ -n "$(git ls-remote --tags origin "refs/tags/$VERSION")" ]; then
57
+ echo "Tag $VERSION already exists — already released, nothing to do."
58
+ echo "version=" >> "$GITHUB_OUTPUT"
59
+ exit 0
48
60
  fi
49
61
 
50
- # Check if this tag already exists on the remote
51
- if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then
52
- echo "Tag $VERSION already exists — skipping release."
62
+ # Past this point the entry is genuinely unreleased.
63
+
64
+ # No date at all is a draft: mergeable to main without releasing.
65
+ if [ -z "${REST//[[:space:]]/}" ]; then
66
+ echo "$VERSION has no date — draft entry, nothing to release."
53
67
  echo "version=" >> "$GITHUB_OUTPUT"
54
68
  exit 0
55
69
  fi
56
70
 
57
- # Check if a GitHub release already exists
71
+ # Anything else has to be a date. The `-` separator is optional, so
72
+ # both `] - 2026-07-31` and `] 2026-07-31` release; anything else is a
73
+ # typo in an entry that was clearly meant to ship, and fails here
74
+ # rather than being mistaken for a draft and skipped in silence.
75
+ if [[ ! "$REST" =~ ^[[:space:]]*-?[[:space:]]*([0-9]{4}-[0-9]{2}-[0-9]{2})[[:space:]]*$ ]]; then
76
+ echo "::error::Changelog heading for $VERSION trails '$(echo "$REST" | xargs)', which is not a YYYY-MM-DD date. Use '## [$VERSION] - YYYY-MM-DD' to release it, or drop the trailing text to keep it a draft."
77
+ exit 1
78
+ fi
79
+ ENTRY_DATE="${BASH_REMATCH[1]}"
80
+
81
+ if ! date -u -d "$ENTRY_DATE" >/dev/null 2>&1; then
82
+ echo "::error::Changelog date $ENTRY_DATE for $VERSION is not a real date"
83
+ exit 1
84
+ fi
85
+
86
+ # The date marks an entry as ready to publish, not as fresh. It may be
87
+ # in the past: when a release attempt fails (say, on tests) the entry
88
+ # stays untagged and the next push to main retries it — the documented
89
+ # behaviour. A date in the future is a typo, though; this workflow has
90
+ # no way to defer a release, so the entry would publish immediately.
91
+ LATEST=$(date -u -d '+12 hours' +%Y-%m-%d) # +12h so any timezone's "today" is accepted
92
+ if [[ "$ENTRY_DATE" > "$LATEST" ]]; then
93
+ echo "::error::Changelog date $ENTRY_DATE for $VERSION is in the future — expected $LATEST or earlier"
94
+ exit 1
95
+ fi
96
+
97
+ # A release with no tag means an earlier run half-completed.
58
98
  if gh release view "$VERSION" >/dev/null 2>&1; then
59
99
  echo "::error::GitHub release $VERSION already exists but tag does not — manual cleanup needed"
60
100
  exit 1
@@ -70,9 +110,17 @@ jobs:
70
110
  echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
71
111
  echo "Detected version=$VERSION prerelease=$PRERELEASE"
72
112
 
73
- release:
113
+ # v1.2.0 shipped while the Test workflow was red: the release ran its own
114
+ # `bun test` on ubuntu only, so a Windows-only failure never reached it.
115
+ # Gate on the whole matrix instead, and only when actually releasing.
116
+ test:
74
117
  needs: detect-release
75
118
  if: needs.detect-release.outputs.version != ''
119
+ uses: ./.github/workflows/test.yml
120
+
121
+ release:
122
+ needs: [detect-release, test]
123
+ if: needs.detect-release.outputs.version != ''
76
124
  runs-on: ubuntu-latest
77
125
  permissions:
78
126
  contents: read
@@ -88,12 +136,14 @@ jobs:
88
136
 
89
137
  - run: bun install --frozen-lockfile
90
138
 
91
- - run: bun test
92
-
93
139
  - name: Set package version
94
140
  run: |
95
141
  VERSION="${{ needs.detect-release.outputs.version }}"
96
- npm version "${VERSION#v}" --no-git-tag-version
142
+ # The changelog is the source of truth; package.json's committed
143
+ # version is only a placeholder. --allow-same-version because
144
+ # `npm version` treats "already that version" as an error, which
145
+ # would fail the release whenever the two happen to agree.
146
+ npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
97
147
 
98
148
  - name: Extract changelog entry
99
149
  id: changelog
@@ -104,13 +154,30 @@ jobs:
104
154
  # Write to file for the release body
105
155
  echo "$BODY" > /tmp/release-body.md
106
156
 
107
- - name: Create Git tag
157
+ - uses: actions/setup-node@v4
158
+ with:
159
+ node-version: 24
160
+ registry-url: https://registry.npmjs.org
161
+
162
+ # npm publish runs BEFORE the tag exists, because detect-release treats
163
+ # the tag as proof the version shipped. Tagging first meant a failed
164
+ # publish (bad npm credentials, say) left a tag behind that made every
165
+ # later run skip the version as already released — the release could
166
+ # never be retried without deleting the tag by hand. Failing here now
167
+ # leaves nothing behind, so the next push to main just tries again.
168
+ - name: Publish to npm
108
169
  run: |
109
- VERSION="${{ needs.detect-release.outputs.version }}"
110
- git tag "$VERSION"
111
- git push origin "$VERSION"
170
+ TAG="latest"
171
+ if [ "${{ needs.detect-release.outputs.prerelease }}" = "true" ]; then
172
+ TAG="next"
173
+ fi
174
+ npm publish --access public --tag "$TAG"
112
175
 
113
- - name: Create GitHub Release
176
+ # One step, not two: gh creates the tag at --target when it doesn't
177
+ # already exist, so there is no window where the tag is visible but the
178
+ # release isn't — which detect-release would read as "already shipped"
179
+ # and skip in silence.
180
+ - name: Create Git tag and GitHub Release
114
181
  env:
115
182
  GH_TOKEN: ${{ secrets.RELEASE_PAT }}
116
183
  run: |
@@ -121,19 +188,7 @@ jobs:
121
188
  FLAGS="--prerelease"
122
189
  fi
123
190
  gh release create "$VERSION" \
191
+ --target "$GITHUB_SHA" \
124
192
  --title "$VERSION" \
125
193
  --notes-file /tmp/release-body.md \
126
194
  $FLAGS
127
-
128
- - uses: actions/setup-node@v4
129
- with:
130
- node-version: 24
131
- registry-url: https://registry.npmjs.org
132
-
133
- - name: Publish to npm
134
- run: |
135
- TAG="latest"
136
- if [ "${{ needs.detect-release.outputs.prerelease }}" = "true" ]; then
137
- TAG="next"
138
- fi
139
- npm publish --access public --tag "$TAG"
@@ -5,6 +5,9 @@ on:
5
5
  branches: [main]
6
6
  pull_request:
7
7
  branches: [main]
8
+ # Called by the release workflow so a release runs this exact matrix, rather
9
+ # than a subset of it that can be green while this one is red.
10
+ workflow_call:
8
11
 
9
12
  jobs:
10
13
  test:
package/CHANGELOG.md CHANGED
@@ -22,6 +22,29 @@ Releases are automated via CI. To create a new release:
22
22
  Stable versions (e.g. `v1.2.0`) publish to npm `@latest`. Pre-release versions
23
23
  (e.g. `v1.2.0-rc.1`) publish to npm `@next` and create a GitHub pre-release.
24
24
 
25
+ ## [v1.2.1] - 2026-08-10
26
+
27
+ ### Fixed
28
+ - **Profile scoping works on Windows.** The per-profile config filename encoded only `/` and `.`, so a Windows `CLAUDE_CONFIG_DIR` kept its backslashes and drive colon — `~/.config/claude-aws-mfa<encoded>.json` then named an NTFS alternate data stream instead of a per-profile file. Separators are normalized and the colon encoded, so a Windows profile gets its own config and lock like everywhere else.
29
+ - **A release can no longer publish over a red build.** The release workflow ran its own `bun test` on Linux only, so v1.2.0 shipped while the Test workflow's Windows leg was failing. It now calls that workflow directly and waits for the full matrix — every OS, GUI tests included — before publishing.
30
+ - Dialog titles on Windows showed the full `C:\Users\you\.claude-profiles\work` instead of `~/.claude-profiles/work`: the home-prefix shortening only recognized `/` as a separator.
31
+
32
+ ## [v1.2.0] - 2026-08-10
33
+
34
+ ### Added
35
+ - **Claude Code profile awareness**: `--setup` now reads and writes `$CLAUDE_CONFIG_DIR/settings.json` rather than always `~/.claude/settings.json`, so a tool like [claude-as](https://github.com/p120ph37/claude-as) can give one project a Bedrock profile while others stay on OAuth. The saved config and lock file are scoped to the profile as well (`~/.config/claude-aws-mfa<encoded-config-dir>.json`), since separate profiles generally mean separate AWS accounts and a shared cached session would otherwise hand a profile credentials for the wrong account. The default `~/.claude` is unsuffixed, so existing installs keep their current config file with no migration. Since the lock is per-profile, two profiles can prompt at the same time, so the credential, error, and setup dialogs all name their profile in the window title and in a banner under the heading. The default config dir is named too — to a multi-profile user an unlabelled dialog reads equally as "this is the default profile" and "this build does not label profiles".
36
+ - **`--no-ui` mode**: skip the GUI entirely and drive the tool from the command line — useful for scripting and for letting an LLM operate the tool during setup/debugging. Prints credentials JSON to stdout on success, keeping stdout cleanly parsable; on failure, prints the underlying error (or names the specific missing config field(s) when the config is incomplete) to stderr. Never imports the GUI toolkit, so it also works in headless environments.
37
+ - **Error dialog**: authentication failures (STS or MFA command) now show a native dialog box instead of writing to stderr, since Claude Code doesn't surface a failing command's stderr to the user. Suppressed in `--no-ui` mode, where the error is printed to stderr instead.
38
+
39
+ ### Changed
40
+ - **Dialogs size themselves to their content.** Every window (main dialog, setup, error) opens small and grows to exactly fit what it renders, instead of using a hardcoded height that had to be guessed and re-guessed whenever the layout changed. Content taller than the cap scrolls rather than being clipped — long error messages are now fully readable. Measured on Linux/WebKitGTK: the main dialog settles at 610px (was hardcoded to 670) and setup at 419px (was 480).
41
+ - `--cache-session`, `--auto-mfa`, and `--single-instance-lock` are now **enabled by default**; use `--no-cache-session`, `--no-auto-mfa`, or `--no-single-instance-lock` to opt out.
42
+ - `--setup` no longer hardcodes an absolute script path into `awsCredentialExport` when the tool is installed and resolvable via `PATH` — it records just the bin name so the setting keeps working across reinstalls/machines. An explicit absolute path is only written as a fallback when running against a local checkout that isn't on `PATH`.
43
+
44
+ ### Fixed
45
+ - **Release workflow no longer fails on every push after a release.** The changelog entry for a shipped version stays at the top of the file, so each later push to `main` re-parsed it — and because the entry was validated *before* the already-released check, it was rejected as a malformed new release (`Changelog date … does not match today`). The already-released check now runs first, and asks the remote for the tag rather than trusting whatever tags `checkout` happened to fetch.
46
+ - A dated-but-unreleased entry is no longer rejected for having a past date, so the documented "re-attempted on each subsequent push to `main`" retry after a failed release actually works. A date in the *future* is still an error, since the workflow has no way to defer a release.
47
+
25
48
  ## [v1.1.0] - 2026-06-04
26
49
 
27
50
  ### Added
package/README.md CHANGED
@@ -20,7 +20,7 @@ Once you have enabled credential-handling and saved the settings, you can launch
20
20
 
21
21
  ### Manual Setup
22
22
 
23
- Add this (plus any addtitional config you prefer) to your Claude Code settings (`~/.claude/settings.json`):
23
+ Add this (plus any addtitional config you prefer) to your Claude Code settings (`~/.claude/settings.json`, or `$CLAUDE_CONFIG_DIR/settings.json` if you use per-project profiles):
24
24
 
25
25
  ```json
26
26
  {
@@ -34,14 +34,15 @@ Add this (plus any addtitional config you prefer) to your Claude Code settings (
34
34
 
35
35
  ## CLI Flags
36
36
 
37
- All flags can be negated with a `--no-` prefix (e.g. `--no-cache-session`). CLI flags override values saved in the config file.
37
+ `--cache-session`, `--auto-mfa`, and `--single-instance-lock` are all **on by default**; use the `--no-` prefix to disable one (e.g. `--no-cache-session`). CLI flags override values saved in the config file.
38
38
 
39
39
  | Flag | Description |
40
40
  |------|-------------|
41
41
  | `--setup` | Launch the guided setup wizard |
42
- | `--cache-session` | Cache STS session credentials and reuse them until they expire |
43
- | `--auto-mfa` | Automatically run the configured MFA command and obtain credentials without showing the dialog (requires MFA Command mode) |
44
- | `--single-instance-lock` | Prevent multiple concurrent instances from showing overlapping dialogs; subsequent invocations wait for the first to finish and reuse its cached session |
42
+ | `--cache-session` | Cache STS session credentials and reuse them until they expire (default: on) |
43
+ | `--auto-mfa` | Automatically run the configured MFA command and obtain credentials without showing the dialog, requires MFA Command mode (default: on) |
44
+ | `--single-instance-lock` | Prevent multiple concurrent instances from showing overlapping dialogs; subsequent invocations wait for the first to finish and reuse its cached session (default: on) |
45
+ | `--no-ui` | Never show any GUI dialog. Prints credentials JSON to stdout on success, or an error to stderr on failure — useful for scripting or letting an LLM drive the tool during setup/debugging |
45
46
 
46
47
  ## How it works
47
48
 
@@ -55,6 +56,21 @@ All flags can be negated with a `--no-` prefix (e.g. `--no-cache-session`). CLI
55
56
 
56
57
  On subsequent runs, all fields are pre-populated from the saved config — just enter a fresh MFA code and hit OK. If you use the command mode, the TOTP code is fetched automatically so no manual entry is needed at all.
57
58
 
59
+ ### Claude Code profiles
60
+
61
+ Claude Code reads its settings from `$CLAUDE_CONFIG_DIR` when that is set, which tools such as [claude-as](https://github.com/p120ph37/claude-as) use to give each project its own credentials. This tool follows suit:
62
+
63
+ - `--setup` reads and writes `$CLAUDE_CONFIG_DIR/settings.json`, so a profile can be Bedrock-only while others stay on OAuth.
64
+ - The config file is scoped per profile — `~/.config/claude-aws-mfa<profile>.json`, where `<profile>` is the config dir path encoded the way Claude encodes project dirs (`/` and `.` both become `-`). Different profiles usually mean different AWS accounts, and a shared cached session would hand a profile credentials for the wrong one.
65
+ - The default `~/.claude` gets no suffix, so existing installs keep using `~/.config/claude-aws-mfa.json` unchanged.
66
+ - Because the lock is per profile too, two profiles can have a dialog open at once. Every dialog names its profile — in the window title and in a banner under the heading — so you can tell which AWS account a prompt is asking for. The default config dir is named `default` rather than left unlabelled, so a blank banner never has to be interpreted.
67
+
68
+ Child processes inherit `CLAUDE_CONFIG_DIR`, so credential export picks up the right profile automatically. `--setup` run from an ordinary shell does not — it configures whatever profile the shell is in, normally the default. To set up a specific profile, export the variable first:
69
+
70
+ ```bash
71
+ CLAUDE_CONFIG_DIR=~/.claude-profiles/work claude-aws-mfa --setup
72
+ ```
73
+
58
74
  ### Session caching
59
75
 
60
76
  With `--cache-session` (or `"cacheSession": true` in the config file), temporary credentials are saved to the config file and reused on subsequent runs until they expire. This avoids prompting for MFA on every invocation.
@@ -65,7 +81,15 @@ With `--auto-mfa` (or `"autoMfa": true` in the config file), when a MFA command
65
81
 
66
82
  ### Single-instance lock
67
83
 
68
- With `--single-instance-lock` (or `"singleInstanceLock": true` in the config file), only one instance of the tool will show a dialog at a time. Additional invocations will wait for the first to finish and then reuse its cached session (when session caching is also enabled). The lock file is stored at `~/.config/claude-aws-mfa.lock` and is automatically cleaned up after 2 minutes if the holding process crashes.
84
+ With `--single-instance-lock` (or `"singleInstanceLock": true` in the config file), only one instance of the tool will show a dialog at a time. Additional invocations will wait for the first to finish and then reuse its cached session (when session caching is also enabled). The lock file is stored alongside the config file (`~/.config/claude-aws-mfa.lock`, per profile) and is automatically cleaned up after 2 minutes if the holding process crashes.
85
+
86
+ ### Authentication failures
87
+
88
+ Claude Code does not surface a failing command's stderr to the user, so when MFA/STS authentication fails, the error is shown in a native dialog box instead of being written to stderr.
89
+
90
+ ### `--no-ui` mode
91
+
92
+ With `--no-ui`, no GUI is ever shown — not even the error dialog above — and the tool never imports the GUI toolkit, so it also works in headless environments without a display. On success, credentials are printed to stdout as usual, keeping stdout cleanly parsable. On failure, an error message is printed to **stderr** instead (e.g. `Missing config: roleArn, mfaCommand` if the config is incomplete, or the underlying STS error if authentication itself failed) and the process exits non-zero. This is intended for scripting and for letting an LLM drive the tool directly while setting up or debugging a configuration — full non-interactive operation requires **MFA Command mode**, since a static "code" mode value can't be re-entered headlessly. The command doesn't need to be a real TOTP generator: for a one-off test or debugging run, `mfaCommand` can simply be `echo <code>` with a fresh, not-yet-used code from your authenticator — it only needs to produce a valid code once.
69
93
 
70
94
  ## System requirements
71
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-aws-mfa",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "repository": "https://github.com/p120ph37/claude-aws-mfa",
6
6
  "bin": {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Page-side half of the self-sizing window contract (see `./autosize`).
3
+ *
4
+ * Kept free of webview-bun so the HTML builders — and their unit tests — never
5
+ * dlopen the native GUI library, which isn't present on a headless runner.
6
+ */
7
+
8
+ /**
9
+ * CSS a page must carry for {@link AUTOSIZE_SCRIPT} to measure correctly.
10
+ *
11
+ * `#fit` is the measured element: `height: auto` shrink-wraps it to its
12
+ * content. Page padding belongs on `#fit` rather than `body` so it counts
13
+ * toward that measurement. Keep floated layout out of `#fit` — a float
14
+ * contributes nothing to its parent's height.
15
+ */
16
+ export const AUTOSIZE_CSS = /*css*/ `
17
+ html, body { height: 100%; }
18
+ /* always-on scrollbar: lets #fit exceed the window and still report its own height */
19
+ body { overflow-y: scroll; }
20
+ #fit { padding: 20px; }
21
+ `;
22
+
23
+ /** Client-side measurement. Pair with {@link AUTOSIZE_CSS} and a `<div id="fit">` wrapper. */
24
+ export const AUTOSIZE_SCRIPT = /*js*/ `
25
+ // Wait for fonts to settle — otherwise reflow moves the height right after we snap to it.
26
+ (document.fonts?.ready ?? Promise.resolve()).then(() => requestAnimationFrame(() => {
27
+ const box = document.getElementById("fit").getBoundingClientRect();
28
+ const delta = Math.ceil(box.height) - innerHeight;
29
+ if (delta) _grow(delta);
30
+ }));
31
+ `;
@@ -0,0 +1,39 @@
1
+ import { SizeHint, Webview } from "webview-bun";
2
+
3
+ /**
4
+ * Self-sizing webview windows.
5
+ *
6
+ * A webview can't be asked how tall its content is from the Bun side, so the
7
+ * page measures itself and reports back. Three things make that measurement
8
+ * trustworthy, and all three are load-bearing:
9
+ *
10
+ * - The window opens at {@link START_HEIGHT}, comfortably above any OS
11
+ * minimum window height. Open it shorter and the WM silently clamps it, so
12
+ * the delta below would be measured against a height we never actually had.
13
+ * - The page reports a *delta* — how much the client area must change — not a
14
+ * target height. `set_size` sizes the whole frame, so a delta cancels out
15
+ * the titlebar, which the page has no way to measure.
16
+ * - The body scrolls, so the measured element may exceed the window and still
17
+ * report its own height instead of being clipped to the viewport.
18
+ *
19
+ * The page-side half of the contract lives in `./autosize-html`.
20
+ */
21
+
22
+ /** Opening height, above any OS minimum so the reported delta isn't measured against a clamped window. */
23
+ export const START_HEIGHT = 160;
24
+
25
+ /**
26
+ * Bind the `_grow` callback the page calls with its measured delta.
27
+ *
28
+ * Must be called before `setHTML`, and the window must have been created at
29
+ * {@link START_HEIGHT} for the delta to land on the right height.
30
+ */
31
+ export function bindAutosize(webview: Webview, width: number, maxHeight: number): void {
32
+ webview.bind("_grow", (delta: number) => {
33
+ webview.size = {
34
+ width,
35
+ height: Math.min(START_HEIGHT + delta, maxHeight),
36
+ hint: SizeHint.FIXED,
37
+ };
38
+ });
39
+ }
@@ -2,7 +2,16 @@ import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
 
5
- const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
5
+ /**
6
+ * Claude Code reads its settings from $CLAUDE_CONFIG_DIR when set, so tools that
7
+ * pin a per-project config dir (e.g. claude-as) get their own settings.json —
8
+ * which is what makes a Bedrock-only profile possible. Child processes inherit
9
+ * the variable, so the credential-export path sees it automatically; `--setup`
10
+ * run from a plain shell does not, and configures the default profile.
11
+ */
12
+ export const claudeConfigDir = (): string => process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
13
+
14
+ const settingsPath = () => join(claudeConfigDir(), "settings.json");
6
15
 
7
16
  export interface ClaudeSettings {
8
17
  env?: Record<string, string>;
@@ -11,14 +20,13 @@ export interface ClaudeSettings {
11
20
 
12
21
  export function loadClaudeSettings(): ClaudeSettings {
13
22
  try {
14
- return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
23
+ return JSON.parse(readFileSync(settingsPath(), "utf-8"));
15
24
  } catch {
16
25
  return {};
17
26
  }
18
27
  }
19
28
 
20
29
  export function saveClaudeSettings(settings: ClaudeSettings): void {
21
- const dir = join(homedir(), ".claude");
22
- mkdirSync(dir, { recursive: true });
23
- writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
30
+ mkdirSync(claudeConfigDir(), { recursive: true });
31
+ writeFileSync(settingsPath(), JSON.stringify(settings, null, 2) + "\n");
24
32
  }
package/src/config.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { homedir } from "os";
2
- import { join, dirname } from "path";
2
+ import { join, dirname, resolve, basename } from "path";
3
3
  import {
4
4
  chmodSync,
5
5
  closeSync,
6
6
  mkdirSync,
7
7
  openSync,
8
8
  readFileSync,
9
+ realpathSync,
9
10
  rmSync,
10
11
  statSync,
11
12
  writeFileSync,
@@ -35,7 +36,70 @@ export interface Config {
35
36
  cachedSession?: CachedSession;
36
37
  }
37
38
 
38
- const CONFIG_PATH = join(homedir(), ".config", "claude-aws-mfa.json");
39
+ /**
40
+ * Suffix that scopes the config (role ARN, keys, cached STS session) to the
41
+ * Claude Code profile in use, since separate profiles generally mean separate
42
+ * AWS accounts — sharing one cached session across them would hand a profile
43
+ * credentials for the wrong account.
44
+ *
45
+ * Empty for the default `~/.claude`, so existing installs keep the unsuffixed
46
+ * file and need no migration. Otherwise the full path is encoded the way Claude
47
+ * encodes project dirs (`/` and `.` both become `-`), rather than the basename,
48
+ * which is not unique across config-dir tools.
49
+ *
50
+ * DETAIL: the encoding is lossy — `/a/b-c` and `/a-b/c` both yield `-a-b-c` —
51
+ * and a hash would not be. That is a deliberate trade: these filenames sit in a
52
+ * directory people read and prune by hand, and approximating the original words
53
+ * is worth more than closing a collision that requires two config dirs whose
54
+ * paths differ only in `/` versus `-`. Claude makes the same trade for
55
+ * `~/.claude/projects`.
56
+ */
57
+ export function profileSuffix(dir = process.env.CLAUDE_CONFIG_DIR): string {
58
+ if (!dir) return "";
59
+ const canonical = (p: string) => {
60
+ let real: string;
61
+ try {
62
+ real = realpathSync(p);
63
+ } catch {
64
+ real = resolve(p);
65
+ }
66
+ return real.replace(/\\/g, "/");
67
+ };
68
+ const real = canonical(dir);
69
+ if (real === canonical(join(homedir(), ".claude"))) return "";
70
+ // `:` goes too, or a Windows drive letter would make the filename an NTFS stream.
71
+ return real.replace(/[/.:]/g, "-");
72
+ }
73
+
74
+ export interface ActiveProfile {
75
+ name: string;
76
+ path: string;
77
+ }
78
+
79
+ export const DEFAULT_PROFILE: ActiveProfile = { name: "default", path: "~/.claude" };
80
+
81
+ /**
82
+ * The profile to name in dialog titles and headings. Because the single-instance
83
+ * lock is per-profile, two profiles can have a dialog on screen at once, asking
84
+ * for credentials to different AWS accounts — so which is which has to be
85
+ * visible.
86
+ *
87
+ * The default config dir is named too, rather than left blank: a multi-profile
88
+ * user facing a single unlabelled dialog cannot tell "this is the default
89
+ * profile" from "this build doesn't label profiles".
90
+ */
91
+ export function activeProfile(dir = process.env.CLAUDE_CONFIG_DIR): ActiveProfile {
92
+ if (!dir || !profileSuffix(dir)) return DEFAULT_PROFILE;
93
+ const clean = dir.replace(/[/\\]+$/, "");
94
+ const home = homedir();
95
+ const underHome = clean === home || (clean.startsWith(home) && /^[/\\]/.test(clean.slice(home.length)));
96
+ return {
97
+ name: basename(clean),
98
+ path: underHome ? ("~" + clean.slice(home.length)).replace(/\\/g, "/") : clean,
99
+ };
100
+ }
101
+
102
+ const CONFIG_PATH = join(homedir(), ".config", `claude-aws-mfa${profileSuffix()}.json`);
39
103
 
40
104
  // Unix file-permission enforcement is skipped on Windows where chmod is a no-op.
41
105
  const IS_UNIX = process.platform !== "win32";
@@ -119,7 +183,8 @@ export function saveConfig(config: Config) {
119
183
 
120
184
  // --- Single-instance lock ---
121
185
 
122
- const LOCK_PATH = join(homedir(), ".config", "claude-aws-mfa.lock");
186
+ // Per-profile too: a profile must not block on, then inherit, another profile's session.
187
+ const LOCK_PATH = join(homedir(), ".config", `claude-aws-mfa${profileSuffix()}.lock`);
123
188
  const LOCK_POLL_MS = 500;
124
189
  const LOCK_STALE_MS = 120_000; // 2 minutes — assume stale if holder crashed
125
190
 
@@ -177,6 +242,22 @@ export async function waitForLock(): Promise<void> {
177
242
  }
178
243
  }
179
244
 
245
+ /**
246
+ * Fields required to obtain credentials with no dialog shown at all (used by
247
+ * --no-ui). A static typed-in MFA code can't be reused headlessly, so
248
+ * command mode with a configured mfaCommand is mandatory here.
249
+ */
250
+ export function missingConfigFields(cfg: Partial<Config>): string[] {
251
+ const missing: string[] = [];
252
+ if (!cfg.region) missing.push("region");
253
+ if (!cfg.accessKeyId) missing.push("accessKeyId");
254
+ if (!cfg.secretAccessKey) missing.push("secretAccessKey");
255
+ if (!cfg.mfaArn) missing.push("mfaArn");
256
+ if (!cfg.roleArn) missing.push("roleArn");
257
+ if (cfg.mfaMode !== "command" || !cfg.mfaCommand) missing.push("mfaCommand");
258
+ return missing;
259
+ }
260
+
180
261
  export async function seedDefaults(): Promise<Partial<Config>> {
181
262
  const defaults: Partial<Config> = {};
182
263
  try {
@@ -0,0 +1,32 @@
1
+ import { realpathSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { bin } from "../package.json";
4
+
5
+ const BIN_NAME = Object.keys(bin)[0];
6
+
7
+ /**
8
+ * Build the command string that Claude Code should use to invoke this tool.
9
+ *
10
+ * Bun always normalizes Bun.argv[1] to an absolute, symlink-resolved path —
11
+ * identically whether launched via a bare PATH-resolved name, a relative path,
12
+ * or an absolute path — so there's no way to recover how this process was
13
+ * actually invoked. Instead, we check whether the bin name resolves via PATH
14
+ * to this same script; if so Claude Code will resolve it the same way, so we
15
+ * record just the bin name and avoid hardcoding a path that could go stale
16
+ * across reinstalls or machines. Only fall back to an explicit path when this
17
+ * isn't on PATH at all (e.g. running against a local dev checkout).
18
+ *
19
+ * This lives in its own module (rather than setup.ts) so it can be imported
20
+ * without pulling in webview-bun, whose native library is loaded at import
21
+ * time and isn't available in headless/unit-test environments.
22
+ */
23
+ export function getCredentialExportCommand(): string {
24
+ const script = Bun.argv[1];
25
+ const onPath = Bun.which(BIN_NAME);
26
+ if (onPath) {
27
+ try {
28
+ if (realpathSync(onPath) === realpathSync(script)) return BIN_NAME;
29
+ } catch {}
30
+ }
31
+ return `${process.execPath} ${resolve(script)}`;
32
+ }