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.
- package/.github/workflows/release.yml +95 -40
- package/.github/workflows/test.yml +3 -0
- package/CHANGELOG.md +23 -0
- package/README.md +30 -6
- package/package.json +1 -1
- package/src/autosize-html.ts +31 -0
- package/src/autosize.ts +39 -0
- package/src/claude-settings.ts +13 -5
- package/src/config.ts +84 -3
- package/src/credential-export.ts +32 -0
- package/src/dialog-html.ts +76 -3
- package/src/dialog.ts +36 -6
- package/src/flags.ts +5 -0
- package/src/index.ts +83 -55
- package/src/setup-html.ts +16 -3
- package/src/setup.ts +12 -15
- package/test/autosize.test.ts +56 -0
- package/test/config.test.ts +82 -1
- package/test/dialog-html.test.ts +53 -1
- package/test/gui-screenshot.test.ts +28 -11
- package/test/index.test.ts +5 -0
- package/test/setup.test.ts +59 -0
|
@@ -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
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
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
|
|
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
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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"
|
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
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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
|
+
`;
|
package/src/autosize.ts
ADDED
|
@@ -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
|
+
}
|
package/src/claude-settings.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|