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.
@@ -1,4 +1,6 @@
1
1
  import { STANDARD_DURATIONS } from "./sts";
2
+ import { AUTOSIZE_CSS, AUTOSIZE_SCRIPT } from "./autosize-html";
3
+ import { DEFAULT_PROFILE, type ActiveProfile } from "./config";
2
4
 
3
5
  export const FIELDS = ["region", "accessKeyId", "secretAccessKey", "mfaArn", "roleArn", "duration", "mfaMode", "mfaCode", "mfaCommand"] as const;
4
6
 
@@ -13,7 +15,30 @@ export const FIELD_PATTERNS: Record<string, string> = {
13
15
  mfaCommand: "^.+$",
14
16
  };
15
17
 
16
- export function buildHtml(config: Record<string, string>) {
18
+ /**
19
+ * Banner naming the Claude Code profile a dialog belongs to. Always shown, so
20
+ * its absence never has to be interpreted. The name is never truncated — it is
21
+ * what tells two dialogs apart — while the path ellipsizes beside it. It sits
22
+ * inside `#fit`, so the window grows to fit it (see `./autosize`).
23
+ */
24
+ export const PROFILE_STYLE = /*css*/ `
25
+ .profile {
26
+ display: flex; gap: 6px; align-items: baseline;
27
+ font-size: 12px; color: #6e6e73; background: #e8e8ed;
28
+ border: 1px solid #d2d2d7; border-radius: 6px;
29
+ padding: 6px 8px; margin: -8px 0 16px;
30
+ }
31
+ .profile b { color: #1d1d1f; font-weight: 600; flex-shrink: 0; }
32
+ .profile span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
33
+ `;
34
+
35
+ export function profileBanner(profile: ActiveProfile): string {
36
+ return /*html*/ `<div class="profile" title="${escapeHtml(profile.path)}">Profile <b>${escapeHtml(
37
+ profile.name,
38
+ )}</b><span>${escapeHtml(profile.path)}</span></div>`;
39
+ }
40
+
41
+ export function buildHtml(config: Record<string, string>, profile: ActiveProfile = DEFAULT_PROFILE) {
17
42
  return /*html*/ `<!DOCTYPE html>
18
43
  <html>
19
44
  <head>
@@ -22,8 +47,9 @@ export function buildHtml(config: Record<string, string>) {
22
47
  * { box-sizing: border-box; margin: 0; padding: 0; }
23
48
  body {
24
49
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
25
- padding: 20px; background: #f5f5f7; color: #1d1d1f;
50
+ background: #f5f5f7; color: #1d1d1f;
26
51
  }
52
+ ${AUTOSIZE_CSS}
27
53
  h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
28
54
  .field { margin-bottom: 12px; }
29
55
  label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; transition: color 0.15s; }
@@ -51,10 +77,13 @@ export function buildHtml(config: Record<string, string>) {
51
77
  }
52
78
  .radio-field input[type="text"] { flex: 1; width: 0; min-width: 0; }
53
79
  input:disabled { background: #f0f0f0; color: #999; cursor: not-allowed; }
80
+ ${PROFILE_STYLE}
54
81
  </style>
55
82
  </head>
56
83
  <body>
84
+ <div id="fit">
57
85
  <h2>Claude AWS MFA</h2>
86
+ ${profileBanner(profile)}
58
87
  <div class="field"><label>AWS Region</label><input id="region" placeholder="us-east-1"></div>
59
88
  <div class="field"><label>Access Key ID</label><input id="accessKeyId" type="password" placeholder="AKIA..."></div>
60
89
  <div class="field"><label>Secret Access Key</label><input id="secretAccessKey" type="password"></div>
@@ -76,7 +105,8 @@ export function buildHtml(config: Record<string, string>) {
76
105
  <button onclick="_cancel()">Cancel</button>
77
106
  <button class="primary" onclick="submit()">OK</button>
78
107
  </div>
79
- <script>
108
+ </div>
109
+ <script>${AUTOSIZE_SCRIPT}
80
110
  document.querySelectorAll("input").forEach(el => {
81
111
  el.setAttribute("autocorrect", "off");
82
112
  el.setAttribute("autocomplete", "off");
@@ -177,3 +207,46 @@ export function buildHtml(config: Record<string, string>) {
177
207
  </body>
178
208
  </html>`;
179
209
  }
210
+
211
+ function escapeHtml(s: string): string {
212
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
213
+ }
214
+
215
+ export function buildErrorHtml(message: string, profile: ActiveProfile = DEFAULT_PROFILE): string {
216
+ return /*html*/ `<!DOCTYPE html>
217
+ <html>
218
+ <head>
219
+ <meta charset="utf-8">
220
+ <style>
221
+ * { box-sizing: border-box; margin: 0; padding: 0; }
222
+ body {
223
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
224
+ background: #f5f5f7; color: #1d1d1f;
225
+ }
226
+ ${AUTOSIZE_CSS}
227
+ h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #ff3b30; }
228
+ p { font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; margin-bottom: 16px; }
229
+ .buttons { display: flex; justify-content: flex-end; }
230
+ button {
231
+ padding: 8px 20px; border-radius: 6px; font-size: 14px; cursor: pointer;
232
+ border: 1px solid #d2d2d7; background: #0071e3; color: #fff;
233
+ }
234
+ button:hover { filter: brightness(0.95); }
235
+ ${PROFILE_STYLE}
236
+ </style>
237
+ </head>
238
+ <body>
239
+ <div id="fit">
240
+ <h2>Authentication Failed</h2>
241
+ ${profileBanner(profile)}
242
+ <p>${escapeHtml(message)}</p>
243
+ <div class="buttons"><button onclick="_ok()">OK</button></div>
244
+ </div>
245
+ <script>${AUTOSIZE_SCRIPT}
246
+ document.addEventListener("keydown", e => {
247
+ if (e.key === "Enter" || e.key === "Escape") _ok();
248
+ });
249
+ </script>
250
+ </body>
251
+ </html>`;
252
+ }
package/src/dialog.ts CHANGED
@@ -1,9 +1,21 @@
1
1
  import { Webview, SizeHint } from "webview-bun";
2
2
  import { lib } from "webview-bun/src/ffi";
3
- import type { Config } from "./config";
4
- import { buildHtml } from "./dialog-html";
3
+ import { activeProfile, type Config } from "./config";
4
+ import { buildHtml, buildErrorHtml } from "./dialog-html";
5
+ import { START_HEIGHT, bindAutosize } from "./autosize";
5
6
  import { version } from "../package.json";
6
7
 
8
+ const DIALOG_WIDTH = 440;
9
+ const DIALOG_MAX_HEIGHT = 800;
10
+
11
+ const ERROR_WIDTH = 420;
12
+ const ERROR_MAX_HEIGHT = 640;
13
+
14
+ /** Window title, naming the profile so stacked dialogs are told apart in the window list too. */
15
+ export function dialogTitle(suffix = ""): string {
16
+ return `Claude AWS MFA v${version} — ${activeProfile().name}${suffix}`;
17
+ }
18
+
7
19
 
8
20
  export interface DialogResult {
9
21
  region: string;
@@ -33,11 +45,12 @@ function clipboardCommand(): string[] {
33
45
 
34
46
  export function showDialog(defaults: Partial<Config>): DialogResult | null {
35
47
  const webview = new Webview(false, {
36
- width: 440,
37
- height: 670,
48
+ width: DIALOG_WIDTH,
49
+ height: START_HEIGHT,
38
50
  hint: SizeHint.FIXED,
39
51
  });
40
- webview.title = `Claude AWS MFA v${version}`;
52
+ webview.title = dialogTitle();
53
+ bindAutosize(webview, DIALOG_WIDTH, DIALOG_MAX_HEIGHT);
41
54
 
42
55
  let result: DialogResult | null = null;
43
56
 
@@ -70,8 +83,25 @@ export function showDialog(defaults: Partial<Config>): DialogResult | null {
70
83
  duration: defaults.duration != null ? String(defaults.duration) : "",
71
84
  mfaCommand: defaults.mfaCommand ?? "",
72
85
  mfaMode: defaults.mfaMode ?? "code",
73
- }));
86
+ }, activeProfile()));
74
87
 
75
88
  webview.run();
76
89
  return result;
77
90
  }
91
+
92
+ /** Show a modal dialog reporting an authentication failure (Claude Code doesn't surface stderr). */
93
+ export function showErrorDialog(message: string): void {
94
+ const webview = new Webview(false, {
95
+ width: ERROR_WIDTH,
96
+ height: START_HEIGHT,
97
+ hint: SizeHint.FIXED,
98
+ });
99
+ webview.title = dialogTitle(" — Error");
100
+ bindAutosize(webview, ERROR_WIDTH, ERROR_MAX_HEIGHT);
101
+
102
+ const handle = webview.unsafeHandle;
103
+ webview.bind("_ok", () => lib.symbols.webview_terminate(handle));
104
+
105
+ webview.setHTML(buildErrorHtml(message, activeProfile()));
106
+ webview.run();
107
+ }
package/src/flags.ts CHANGED
@@ -3,6 +3,7 @@ export interface CliFlags {
3
3
  cacheSession: boolean | undefined; // undefined = use config default
4
4
  autoMfa: boolean | undefined;
5
5
  singleInstanceLock: boolean | undefined;
6
+ noUi: boolean;
6
7
  }
7
8
 
8
9
  export function parseFlags(argv: string[]): CliFlags {
@@ -11,6 +12,7 @@ export function parseFlags(argv: string[]): CliFlags {
11
12
  cacheSession: undefined,
12
13
  autoMfa: undefined,
13
14
  singleInstanceLock: undefined,
15
+ noUi: false,
14
16
  };
15
17
  for (const arg of argv.slice(2)) {
16
18
  switch (arg) {
@@ -36,6 +38,9 @@ export function parseFlags(argv: string[]): CliFlags {
36
38
  case "--no-single-instance-lock":
37
39
  flags.singleInstanceLock = false;
38
40
  break;
41
+ case "--no-ui":
42
+ flags.noUi = true;
43
+ break;
39
44
  }
40
45
  }
41
46
  return flags;
package/src/index.ts CHANGED
@@ -25,8 +25,7 @@ if (flags.setup) {
25
25
  }
26
26
 
27
27
  import type { CachedSession, Config } from "./config";
28
- import { loadConfig, saveConfig, seedDefaults, tryAcquireLock, releaseLock, waitForLock } from "./config";
29
- import { showDialog } from "./dialog";
28
+ import { loadConfig, saveConfig, seedDefaults, tryAcquireLock, releaseLock, waitForLock, missingConfigFields } from "./config";
30
29
  import { assumeRoleWithMfa, STANDARD_DURATIONS } from "./sts";
31
30
 
32
31
  // --- Resolve effective flags (CLI overrides config defaults) ---
@@ -34,9 +33,9 @@ import { assumeRoleWithMfa, STANDARD_DURATIONS } from "./sts";
34
33
  const config = loadConfig();
35
34
  const defaults: Partial<Config> = config ?? await seedDefaults();
36
35
 
37
- const useCache = flags.cacheSession ?? defaults.cacheSession ?? false;
38
- const useAutoMfa = flags.autoMfa ?? defaults.autoMfa ?? false;
39
- const useLock = flags.singleInstanceLock ?? defaults.singleInstanceLock ?? false;
36
+ const useCache = flags.cacheSession ?? defaults.cacheSession ?? true;
37
+ const useAutoMfa = flags.autoMfa ?? defaults.autoMfa ?? true;
38
+ const useLock = flags.singleInstanceLock ?? defaults.singleInstanceLock ?? true;
40
39
 
41
40
  // --- Helper: check if a cached session is still valid ---
42
41
 
@@ -57,59 +56,73 @@ function outputCredentials(session: CachedSession): void {
57
56
  }));
58
57
  }
59
58
 
59
+ // --- Helper: report a failure, respecting --no-ui (dialog is never shown in that mode) ---
60
+
61
+ async function reportFailure(message: string): Promise<never> {
62
+ if (flags.noUi) {
63
+ // stdout is reserved for cleanly-parsable credentials JSON on success.
64
+ process.stderr.write(`${message}\n`);
65
+ } else {
66
+ const { showErrorDialog } = await import("./dialog");
67
+ showErrorDialog(message);
68
+ }
69
+ process.exit(2);
70
+ }
71
+
60
72
  // --- Helper: run MFA command and attempt to obtain credentials without dialog ---
73
+ // Throws (rather than returning null) on failure so callers can decide whether to
74
+ // fall back to the dialog (interactive) or surface the error directly (--no-ui).
61
75
 
62
76
  async function tryAutoMfa(cfg: Partial<Config>): Promise<CachedSession | null> {
63
77
  if (!cfg.mfaCommand || cfg.mfaMode !== "command") return null;
64
78
  if (!cfg.region || !cfg.accessKeyId || !cfg.secretAccessKey || !cfg.mfaArn || !cfg.roleArn) return null;
65
79
 
66
- try {
67
- const proc = Bun.spawnSync(["sh", "-c", cfg.mfaCommand]);
68
- if (proc.exitCode !== 0) return null;
69
- const mfaCode = proc.stdout.toString().trim();
70
- if (!mfaCode) return null;
71
-
72
- const { credentials, duration } = await assumeRoleWithMfa({
73
- region: cfg.region,
74
- accessKeyId: cfg.accessKeyId,
75
- secretAccessKey: cfg.secretAccessKey,
76
- mfaArn: cfg.mfaArn,
77
- roleArn: cfg.roleArn,
78
- mfaCode,
79
- duration: cfg.duration ?? STANDARD_DURATIONS[0],
80
- });
81
-
82
- const session: CachedSession = {
83
- AccessKeyId: credentials.AccessKeyId,
84
- SecretAccessKey: credentials.SecretAccessKey,
85
- SessionToken: credentials.SessionToken,
86
- Expiration: credentials.Expiration,
87
- };
88
-
89
- saveConfig({
90
- region: cfg.region,
91
- accessKeyId: cfg.accessKeyId,
92
- secretAccessKey: cfg.secretAccessKey,
93
- mfaArn: cfg.mfaArn,
94
- roleArn: cfg.roleArn,
95
- duration,
96
- mfaMode: cfg.mfaMode,
97
- mfaCommand: cfg.mfaCommand,
98
- cacheSession: cfg.cacheSession,
99
- autoMfa: cfg.autoMfa,
100
- singleInstanceLock: cfg.singleInstanceLock,
101
- ...(useCache ? { cachedSession: session } : {}),
102
- });
103
-
104
- return session;
105
- } catch {
106
- return null;
80
+ const proc = Bun.spawnSync(["sh", "-c", cfg.mfaCommand]);
81
+ if (proc.exitCode !== 0) {
82
+ throw new Error(`MFA command failed: ${proc.stderr.toString().trim()}`);
107
83
  }
84
+ const mfaCode = proc.stdout.toString().trim();
85
+ if (!mfaCode) throw new Error("MFA command produced no output");
86
+
87
+ const { credentials, duration } = await assumeRoleWithMfa({
88
+ region: cfg.region,
89
+ accessKeyId: cfg.accessKeyId,
90
+ secretAccessKey: cfg.secretAccessKey,
91
+ mfaArn: cfg.mfaArn,
92
+ roleArn: cfg.roleArn,
93
+ mfaCode,
94
+ duration: cfg.duration ?? STANDARD_DURATIONS[0],
95
+ });
96
+
97
+ const session: CachedSession = {
98
+ AccessKeyId: credentials.AccessKeyId,
99
+ SecretAccessKey: credentials.SecretAccessKey,
100
+ SessionToken: credentials.SessionToken,
101
+ Expiration: credentials.Expiration,
102
+ };
103
+
104
+ saveConfig({
105
+ region: cfg.region,
106
+ accessKeyId: cfg.accessKeyId,
107
+ secretAccessKey: cfg.secretAccessKey,
108
+ mfaArn: cfg.mfaArn,
109
+ roleArn: cfg.roleArn,
110
+ duration,
111
+ mfaMode: cfg.mfaMode,
112
+ mfaCommand: cfg.mfaCommand,
113
+ cacheSession: cfg.cacheSession,
114
+ autoMfa: cfg.autoMfa,
115
+ singleInstanceLock: cfg.singleInstanceLock,
116
+ ...(useCache ? { cachedSession: session } : {}),
117
+ });
118
+
119
+ return session;
108
120
  }
109
121
 
110
122
  // --- Helper: show dialog and obtain credentials ---
111
123
 
112
124
  async function obtainViaDialog(cfg: Partial<Config>): Promise<void> {
125
+ const { showDialog } = await import("./dialog");
113
126
  const result = showDialog(cfg);
114
127
  if (!result) {
115
128
  process.stderr.write("User cancelled dialog.\n");
@@ -120,8 +133,7 @@ async function obtainViaDialog(cfg: Partial<Config>): Promise<void> {
120
133
  if (result.mfaMode === "command") {
121
134
  const proc = Bun.spawnSync(["sh", "-c", result.mfaCommand]);
122
135
  if (proc.exitCode !== 0) {
123
- process.stderr.write(`MFA command failed: ${proc.stderr.toString()}\n`);
124
- process.exit(2);
136
+ throw new Error(`MFA command failed: ${proc.stderr.toString().trim()}`);
125
137
  }
126
138
  mfaCode = proc.stdout.toString().trim();
127
139
  }
@@ -177,14 +189,29 @@ try {
177
189
 
178
190
  // 2. If auto-mfa is enabled, try to obtain credentials without dialog
179
191
  if (useAutoMfa) {
180
- const session = await tryAutoMfa(defaults);
181
- if (session) {
182
- outputCredentials(session);
183
- process.exit(0);
192
+ try {
193
+ const session = await tryAutoMfa(defaults);
194
+ if (session) {
195
+ outputCredentials(session);
196
+ process.exit(0);
197
+ }
198
+ } catch (err) {
199
+ // In --no-ui mode there's no dialog to fall back to — surface the error directly.
200
+ if (flags.noUi) throw err;
184
201
  }
185
202
  }
186
203
 
187
- // 3. If single-instance-lock is enabled, acquire lock before showing dialog
204
+ // 3. In --no-ui mode, never show a dialog — report why credentials couldn't be obtained.
205
+ if (flags.noUi) {
206
+ const missing = missingConfigFields(defaults);
207
+ if (missing.length) {
208
+ await reportFailure(`Missing config: ${missing.join(", ")}`);
209
+ } else {
210
+ await reportFailure("Unable to obtain credentials without UI: auto-MFA did not succeed.");
211
+ }
212
+ }
213
+
214
+ // 4. If single-instance-lock is enabled, acquire lock before showing dialog
188
215
  if (useLock) {
189
216
  if (!tryAcquireLock()) {
190
217
  // Another instance holds the lock — wait for it
@@ -232,6 +259,7 @@ try {
232
259
  const masked = raw
233
260
  .replace(/(?:AKIA|ASIA)[A-Z0-9]{16}/g, "****")
234
261
  .replace(/[A-Za-z0-9/+=]{40}/g, "****");
235
- process.stderr.write(`STS AssumeRole failed: ${masked}\n`);
236
- process.exit(2);
262
+ // Claude Code does not surface stderr to the user, so failures are reported via
263
+ // a dialog box instead (or to stderr in --no-ui mode, keeping stdout parsable).
264
+ await reportFailure(`Authentication failed: ${masked}`);
237
265
  }
package/src/setup-html.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { ClaudeSettings } from "./claude-settings";
2
+ import { AUTOSIZE_CSS, AUTOSIZE_SCRIPT } from "./autosize-html";
3
+ import { DEFAULT_PROFILE, type ActiveProfile } from "./config";
4
+ import { PROFILE_STYLE, profileBanner } from "./dialog-html";
2
5
 
3
6
  export const TEXT_FIELDS = [
4
7
  { key: "ANTHROPIC_DEFAULT_OPUS_MODEL", label: "Opus Model", placeholder: "global.anthropic.claude-opus-4-6" },
@@ -7,7 +10,12 @@ export const TEXT_FIELDS = [
7
10
  { key: "CLAUDE_CODE_SUBAGENT_MODEL", label: "Subagent Model", placeholder: "global.anthropic.claude-sonnet-4-6" },
8
11
  ] as const;
9
12
 
10
- export function buildSetupHtml(env: Record<string, string>, settings: ClaudeSettings, credentialExportCmd: string) {
13
+ export function buildSetupHtml(
14
+ env: Record<string, string>,
15
+ settings: ClaudeSettings,
16
+ credentialExportCmd: string,
17
+ profile: ActiveProfile = DEFAULT_PROFILE,
18
+ ) {
11
19
  const bedrockMfaEnabled = env.CLAUDE_CODE_USE_BEDROCK === "1"
12
20
  && settings.awsCredentialExport !== undefined
13
21
  && env.AWS_SHARED_CREDENTIALS_FILE === "/dev/null";
@@ -32,8 +40,9 @@ export function buildSetupHtml(env: Record<string, string>, settings: ClaudeSett
32
40
  * { box-sizing: border-box; margin: 0; padding: 0; }
33
41
  body {
34
42
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
35
- padding: 20px; background: #f5f5f7; color: #1d1d1f;
43
+ background: #f5f5f7; color: #1d1d1f;
36
44
  }
45
+ ${AUTOSIZE_CSS}
37
46
  h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
38
47
  .field { margin-bottom: 12px; }
39
48
  label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #1d1d1f; }
@@ -59,10 +68,13 @@ export function buildSetupHtml(env: Record<string, string>, settings: ClaudeSett
59
68
  }
60
69
  button.primary { background: #0071e3; color: #fff; border-color: #0071e3; }
61
70
  button:hover { filter: brightness(0.95); }
71
+ ${PROFILE_STYLE}
62
72
  </style>
63
73
  </head>
64
74
  <body>
75
+ <div id="fit">
65
76
  <h2>Claude Code Bedrock Setup</h2>
77
+ ${profileBanner(profile)}
66
78
 
67
79
  ${bedrockMfaCheckboxHtml}
68
80
 
@@ -74,8 +86,9 @@ export function buildSetupHtml(env: Record<string, string>, settings: ClaudeSett
74
86
  <button onclick="_cancel()">Cancel</button>
75
87
  <button class="primary" onclick="save()">Save</button>
76
88
  </div>
89
+ </div>
77
90
 
78
- <script>
91
+ <script>${AUTOSIZE_SCRIPT}
79
92
  const TEXT_FIELDS = ${JSON.stringify(TEXT_FIELDS)};
80
93
  const originalEnv = ${JSON.stringify(env)};
81
94
  const originalSettings = ${JSON.stringify(settings)};
package/src/setup.ts CHANGED
@@ -1,32 +1,29 @@
1
- import { resolve } from "path";
2
1
  import { Webview, SizeHint } from "webview-bun";
3
2
  import { lib } from "webview-bun/src/ffi";
4
3
  import { loadClaudeSettings, saveClaudeSettings } from "./claude-settings";
4
+ import { activeProfile } from "./config";
5
5
  import { buildSetupHtml } from "./setup-html";
6
+ import { getCredentialExportCommand } from "./credential-export";
7
+ import { START_HEIGHT, bindAutosize } from "./autosize";
6
8
  import { version } from "../package.json";
7
9
 
8
- /** Build the command string that Claude Code should use to invoke this tool. */
9
- function getCredentialExportCommand(): string {
10
- const script = Bun.argv[1];
11
- // If the script has a .ts or .js extension, it was launched via interpreter
12
- if (/\.[tj]sx?$/.test(script)) {
13
- return `${process.execPath} ${resolve(script)}`;
14
- }
15
- // Otherwise it's an installed bin — use the bin path directly
16
- return script;
17
- }
10
+ const SETUP_WIDTH = 480;
11
+ const SETUP_MAX_HEIGHT = 720;
18
12
 
19
13
  export function runSetup(): boolean {
20
14
  const settings = loadClaudeSettings();
21
15
  const env = (settings.env ?? {}) as Record<string, string>;
22
16
  const credentialExportCmd = getCredentialExportCommand();
23
17
 
18
+ // Setup edits $CLAUDE_CONFIG_DIR/settings.json, so name the profile it will land in.
19
+ const profile = activeProfile();
24
20
  const webview = new Webview(false, {
25
- width: 480,
26
- height: 480,
21
+ width: SETUP_WIDTH,
22
+ height: START_HEIGHT,
27
23
  hint: SizeHint.FIXED,
28
24
  });
29
- webview.title = `Claude Code Bedrock Setup v${version}`;
25
+ webview.title = `Claude Code Bedrock Setup v${version} — ${profile.name}`;
26
+ bindAutosize(webview, SETUP_WIDTH, SETUP_MAX_HEIGHT);
30
27
 
31
28
  let saved = false;
32
29
  const handle = webview.unsafeHandle;
@@ -65,7 +62,7 @@ export function runSetup(): boolean {
65
62
 
66
63
  webview.bind("_cancel", () => lib.symbols.webview_terminate(handle));
67
64
 
68
- webview.setHTML(buildSetupHtml(env, settings, credentialExportCmd));
65
+ webview.setHTML(buildSetupHtml(env, settings, credentialExportCmd, profile));
69
66
  webview.run();
70
67
 
71
68
  return saved;
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { AUTOSIZE_CSS, AUTOSIZE_SCRIPT } from "../src/autosize-html";
3
+ import { buildHtml, buildErrorHtml } from "../src/dialog-html";
4
+ import { buildSetupHtml } from "../src/setup-html";
5
+
6
+ const PAGES: Record<string, string> = {
7
+ dialog: buildHtml({ region: "us-east-1" }),
8
+ error: buildErrorHtml("something went wrong"),
9
+ setup: buildSetupHtml({}, {}, "claude-aws-mfa"),
10
+ };
11
+
12
+ describe("autosize contract", () => {
13
+ for (const [name, html] of Object.entries(PAGES)) {
14
+ describe(name, () => {
15
+ test("carries the measurement CSS and script", () => {
16
+ expect(html).toContain(AUTOSIZE_CSS);
17
+ expect(html).toContain(AUTOSIZE_SCRIPT);
18
+ });
19
+
20
+ test("wraps its content in the measured #fit element", () => {
21
+ expect(html).toContain(`<div id="fit">`);
22
+ // The wrapper must close before </body>, or it would measure nothing.
23
+ expect(html.indexOf(`<div id="fit">`)).toBeLessThan(html.indexOf("</body>"));
24
+ });
25
+
26
+ test("keeps page padding on #fit, not body", () => {
27
+ // Padding on body sits outside #fit's box and would be measured away.
28
+ const bodyRule = html.match(/\n body \{[^}]*\}/)?.[0] ?? "";
29
+ expect(bodyRule).not.toContain("padding");
30
+ });
31
+ });
32
+ }
33
+ });
34
+
35
+ describe("AUTOSIZE_SCRIPT", () => {
36
+ test("reports a delta rather than an absolute height", () => {
37
+ // set_size sizes the whole frame; only a delta cancels out the titlebar.
38
+ expect(AUTOSIZE_SCRIPT).toContain("- innerHeight");
39
+ expect(AUTOSIZE_SCRIPT).toContain("_grow(delta)");
40
+ });
41
+
42
+ test("waits for fonts before measuring", () => {
43
+ expect(AUTOSIZE_SCRIPT).toContain("document.fonts?.ready");
44
+ expect(AUTOSIZE_SCRIPT).toContain("requestAnimationFrame");
45
+ });
46
+
47
+ test("skips the resize when the content already fits", () => {
48
+ expect(AUTOSIZE_SCRIPT).toContain("if (delta)");
49
+ });
50
+ });
51
+
52
+ describe("AUTOSIZE_CSS", () => {
53
+ test("keeps the body scrollable so #fit can exceed the viewport", () => {
54
+ expect(AUTOSIZE_CSS).toContain("overflow-y: scroll");
55
+ });
56
+ });
@@ -1,7 +1,50 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, statSync, openSync, closeSync } from "fs";
3
- import { join } from "path";
3
+ import { join, resolve } from "path";
4
4
  import { tmpdir } from "os";
5
+ import { homedir } from "os";
6
+ import { activeProfile, missingConfigFields, profileSuffix } from "../src/config";
7
+
8
+ describe("profileSuffix", () => {
9
+ test("is empty for no profile and for the default config dir", () => {
10
+ expect(profileSuffix("")).toBe(""); // CLAUDE_CONFIG_DIR unset
11
+ expect(profileSuffix(join(homedir(), ".claude"))).toBe("");
12
+ expect(profileSuffix(join(homedir(), ".claude") + "/")).toBe(""); // trailing slash still grandfathered
13
+ });
14
+
15
+ test("encodes the full path, not the basename, with / and . both as -", () => {
16
+ // resolve() puts a drive letter in front on Windows: "-" on POSIX, "D--" there.
17
+ const root = resolve("/").replace(/[/\\:]/g, "-");
18
+ expect(profileSuffix("/opt/profiles/work")).toBe(root + "opt-profiles-work");
19
+ // basename alone would collide; the full path keeps them distinct
20
+ expect(profileSuffix("/a/work")).not.toBe(profileSuffix("/b/work"));
21
+ expect(profileSuffix("/home/me/.claude-profiles/work")).toBe(root + "home-me--claude-profiles-work");
22
+ // whatever the platform, the result has to be usable as a filename component
23
+ expect(profileSuffix("/opt/profiles/work")).not.toMatch(/[/\\:]/);
24
+ });
25
+ });
26
+
27
+ describe("activeProfile", () => {
28
+ test("names the default config dir rather than returning nothing", () => {
29
+ // A blank banner would be ambiguous: "default profile" vs "build doesn't label profiles".
30
+ expect(activeProfile("")).toEqual({ name: "default", path: "~/.claude" });
31
+ expect(activeProfile(join(homedir(), ".claude"))).toEqual({ name: "default", path: "~/.claude" });
32
+ });
33
+
34
+ test("names the profile and shortens the home prefix", () => {
35
+ expect(activeProfile(join(homedir(), ".claude-profiles", "work"))).toEqual({
36
+ name: "work",
37
+ path: "~/.claude-profiles/work",
38
+ });
39
+ expect(activeProfile("/opt/profiles/work")).toEqual({ name: "work", path: "/opt/profiles/work" });
40
+ expect(activeProfile("/opt/profiles/work/")).toEqual({ name: "work", path: "/opt/profiles/work" });
41
+ });
42
+
43
+ test("does not shorten a sibling directory that merely shares the home prefix", () => {
44
+ const sibling = homedir() + "-other/work";
45
+ expect(activeProfile(sibling)?.path).toBe(sibling);
46
+ });
47
+ });
5
48
 
6
49
  describe("config serialization", () => {
7
50
  const config = {
@@ -80,6 +123,44 @@ describe("config file I/O", () => {
80
123
  });
81
124
  });
82
125
 
126
+ describe("missingConfigFields", () => {
127
+ const complete = {
128
+ region: "us-west-2",
129
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
130
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
131
+ mfaArn: "arn:aws:iam::123456789012:mfa/user",
132
+ roleArn: "arn:aws:iam::123456789012:role/TestRole",
133
+ mfaMode: "command" as const,
134
+ mfaCommand: "op item get --otp myitem",
135
+ };
136
+
137
+ test("returns empty array when everything needed for headless operation is present", () => {
138
+ expect(missingConfigFields(complete)).toEqual([]);
139
+ });
140
+
141
+ test("reports each missing base field by name", () => {
142
+ const { region, ...rest } = complete;
143
+ expect(missingConfigFields(rest)).toContain("region");
144
+ });
145
+
146
+ test("requires mfaCommand even if other fields are present", () => {
147
+ const { mfaCommand, ...rest } = complete;
148
+ expect(missingConfigFields(rest)).toContain("mfaCommand");
149
+ });
150
+
151
+ test("requires mfaMode to be \"command\", not just mfaCommand set", () => {
152
+ const codeMode = { ...complete, mfaMode: "code" as const };
153
+ expect(missingConfigFields(codeMode)).toContain("mfaCommand");
154
+ });
155
+
156
+ test("reports everything missing for an empty config", () => {
157
+ const missing = missingConfigFields({});
158
+ expect(missing).toEqual([
159
+ "region", "accessKeyId", "secretAccessKey", "mfaArn", "roleArn", "mfaCommand",
160
+ ]);
161
+ });
162
+ });
163
+
83
164
  const isWindows = process.platform === "win32";
84
165
 
85
166
  // Unix file-permission semantics don't apply on Windows.