brolly 0.1.0__py3-none-any.whl

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.
brolly/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.1.0'
brolly/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from brolly.cli import app
2
+
3
+ if __name__ == '__main__':
4
+ app()
brolly/cli.py ADDED
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env python3
2
+ """brolly — authenticate, repoint, or create AWS SSO profiles.
3
+
4
+ usage:
5
+ brolly verify/refresh the current profile (same as `brolly refresh`)
6
+ brolly login [-s <session>] force a fresh device-code login for a session
7
+ brolly switch pick a new account/role for $AWS_PROFILE (rewrites the profile in place)
8
+ brolly refresh [<profile>] [-s <session>]
9
+ cheaply verify/refresh a profile's credentials, logging in only if the
10
+ session is dead; defaults to $AWS_PROFILE
11
+ brolly add <profile> [-s <session>]
12
+ create a new profile under an existing sso-session, pick its account/role,
13
+ and leave it authenticated
14
+
15
+ Bare `brolly` no longer force-logs-in — it refreshes the current profile; use `brolly login` for a forced login.
16
+
17
+ -s/--session asserts which sso-session you are operating under (default: the current $AWS_PROFILE's).
18
+ For refresh the target profile must actually belong to the asserted session, or the command fails loudly —
19
+ crossing sessions is always deliberate.
20
+
21
+ $AWS_PROFILE is never modified — it is the fixed handle; `switch` only changes what it resolves to, `add`
22
+ creates a new profile without changing which one the shell uses, and `refresh` targets the aws CLI with
23
+ --profile rather than touching the ambient env.
24
+ """
25
+
26
+ import argparse
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import termios
32
+ import tty
33
+ from typing import Any
34
+
35
+ import boto3
36
+ import botocore.session
37
+ from botocore.exceptions import SSOTokenLoadError, TokenRetrievalError, UnauthorizedSSOTokenError
38
+ from botocore.tokens import create_token_resolver
39
+
40
+ type ProfileName = str
41
+ type SessionName = str
42
+ type Region = str
43
+ type AccountId = str
44
+ type RoleName = str
45
+ type AccessToken = str
46
+ type AwsConfig = dict[str, Any] # botocore's full_config / scoped-config nested-dict shape
47
+ type Account = dict[str, str] # a boto3 sso.list_accounts entry: accountId, accountName, emailAddress
48
+
49
+ _ORANGE = '\033[38;5;214m'
50
+ _DIM = '\033[2m'
51
+ _RESET = '\033[0m'
52
+
53
+ # nerd-font glyphs (match the shell prompt's AWS pill)
54
+ _AWS = '' # amazon
55
+ _ACCT = '' # institution / account
56
+ _ROLE = '' # key / role
57
+ _CURSOR = '' # caret-right
58
+ _CURRENT = '' # check-circle
59
+ _CHECK = '' # check
60
+
61
+
62
+ def _require_aws() -> None:
63
+ """Fail early with install guidance if the AWS CLI v2 is not on PATH — brolly shells out to `aws`."""
64
+ if shutil.which('aws') is None:
65
+ raise SystemExit(
66
+ 'brolly needs the AWS CLI v2 (`aws`) on your PATH. Install it: '
67
+ 'https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html'
68
+ )
69
+
70
+
71
+ def _aws(*args: str, capture: bool = True) -> subprocess.CompletedProcess[str]:
72
+ """Run the aws CLI. capture=False lets an interactive command own the terminal (device-code login)."""
73
+ result = subprocess.run(['aws', *args], text=True, capture_output=capture)
74
+ if result.returncode != 0:
75
+ detail = (result.stderr or '').strip() if capture else ''
76
+ raise SystemExit(f'aws {" ".join(args)} failed{": " + detail if detail else ""}')
77
+ return result
78
+
79
+
80
+ def _profile_sso(profile: ProfileName) -> tuple[SessionName, Region, AccountId | None, RoleName | None]:
81
+ """Return (session, region, account_id, role) for the profile; exit if it is not an SSO profile."""
82
+ session = botocore.session.Session(profile=profile)
83
+ cfg = session.get_scoped_config()
84
+ session_name: SessionName | None = cfg.get('sso_session')
85
+ if not session_name:
86
+ raise SystemExit(f"profile '{profile}' has no sso_session")
87
+ region: Region = session.full_config['sso_sessions'][session_name]['sso_region']
88
+ return session_name, region, cfg.get('sso_account_id'), cfg.get('sso_role_name')
89
+
90
+
91
+ def _resolve_token(profile: ProfileName) -> AccessToken | None:
92
+ """A valid SSO access token — refreshed if the hourly one lapsed; None if the 7-day session is dead."""
93
+ resolver = create_token_resolver(botocore.session.Session(profile=profile))
94
+ try:
95
+ token = resolver.load_token()
96
+ return token.get_frozen_token().token if token is not None else None
97
+ except TokenRetrievalError, SSOTokenLoadError, UnauthorizedSSOTokenError:
98
+ return None
99
+
100
+
101
+ def _ensure_token(profile: ProfileName, session_name: SessionName) -> AccessToken:
102
+ token = _resolve_token(profile)
103
+ if token is not None:
104
+ return token
105
+ # session fully expired (>7d): the refresh token is dead, so log in interactively, then resolve again
106
+ print(f"SSO session '{session_name}' expired — logging in…", file=sys.stderr)
107
+ _aws('sso', 'login', '--sso-session', session_name, '--no-browser', '--use-device-code', capture=False)
108
+ token = _resolve_token(profile)
109
+ if token is None:
110
+ raise SystemExit('could not obtain a valid SSO token after login')
111
+ return token
112
+
113
+
114
+ def _read_key() -> str:
115
+ ch = sys.stdin.read(1)
116
+ if ch == '\x1b':
117
+ return {'[A': 'up', '[B': 'down'}.get(sys.stdin.read(2), 'esc')
118
+ if ch in ('\r', '\n'):
119
+ return 'enter'
120
+ if ch == '\x03':
121
+ return 'ctrl-c'
122
+ return ch
123
+
124
+
125
+ def _render(rows: list[str], idx: int, current: int) -> None:
126
+ for i, row in enumerate(rows):
127
+ cursor = f'{_ORANGE}{_CURSOR}{_RESET}' if i == idx else ' '
128
+ dot = f'{_ORANGE}{_CURRENT}{_RESET}' if i == current else ' '
129
+ body = f'\033[7m {row} {_RESET}' if i == idx else f' {row} '
130
+ sys.stdout.write(f'\r\033[K {cursor} {dot} {body}\n')
131
+
132
+
133
+ def _menu(header: str, columns: list[str], rows: list[list[str]], default: int, current: int) -> int:
134
+ """Arrow-key selector over tabular rows; returns the chosen index. Falls back to numeric input off a TTY."""
135
+ widths = [max(len(columns[c]), *(len(r[c]) for r in rows)) for c in range(len(columns))]
136
+ lines = [' '.join(cell.ljust(widths[c]) for c, cell in enumerate(row)) for row in rows]
137
+ head = ' '.join(col.ljust(widths[c]) for c, col in enumerate(columns))
138
+
139
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
140
+ print(f'\n{header}\n {head}')
141
+ for i, line in enumerate(lines):
142
+ print(f' {i + 1:>2}) {line}{" ← current" if i == current else ""}')
143
+ while True:
144
+ try:
145
+ raw = input(f'select [1-{len(rows)}] (default {default + 1})> ').strip()
146
+ except EOFError:
147
+ raise SystemExit(130) from None
148
+ if not raw:
149
+ return default
150
+ if raw.isdigit() and 1 <= int(raw) <= len(rows):
151
+ return int(raw) - 1
152
+
153
+ idx = default
154
+ print(f'\n{header} {_DIM}↑/↓ move · enter select · q quit{_RESET}')
155
+ print(f' {_DIM}{head}{_RESET}')
156
+ _render(lines, idx, current)
157
+ fd = sys.stdin.fileno()
158
+ saved = termios.tcgetattr(fd)
159
+ sys.stdout.write('\033[?25l')
160
+ sys.stdout.flush()
161
+ try:
162
+ tty.setcbreak(fd)
163
+ while True:
164
+ key = _read_key()
165
+ if key in ('up', 'k'):
166
+ idx = (idx - 1) % len(rows)
167
+ elif key in ('down', 'j'):
168
+ idx = (idx + 1) % len(rows)
169
+ elif key == 'enter':
170
+ return idx
171
+ elif key in ('q', 'esc', 'ctrl-c'):
172
+ raise SystemExit(130)
173
+ else:
174
+ continue
175
+ sys.stdout.write(f'\033[{len(rows)}A')
176
+ _render(lines, idx, current)
177
+ sys.stdout.flush()
178
+ finally:
179
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
180
+ sys.stdout.write('\033[?25h')
181
+ sys.stdout.flush()
182
+
183
+
184
+ def cmd_login(session: SessionName) -> None:
185
+ _aws('sso', 'login', '--sso-session', session, '--no-browser', '--use-device-code', capture=False)
186
+
187
+
188
+ def _pick_account_role(
189
+ sso_region: Region, token: AccessToken, cur_account: AccountId | None = None, cur_role: RoleName | None = None
190
+ ) -> tuple[Account, RoleName]:
191
+ """Interactively choose an account and role for the session; pre-selects cur_account/cur_role when given."""
192
+ sso = boto3.client('sso', region_name=sso_region)
193
+
194
+ accounts: list[Account] = []
195
+ for page in sso.get_paginator('list_accounts').paginate(accessToken=token):
196
+ accounts.extend(page['accountList'])
197
+ if not accounts:
198
+ raise SystemExit('no accounts available for this session')
199
+ accounts.sort(key=lambda a: a['accountName'])
200
+
201
+ acct_current = next((i for i, a in enumerate(accounts) if a['accountId'] == cur_account), -1)
202
+ rows = [[a['accountId'], a['accountName']] for a in accounts]
203
+ header = f'{_AWS} {_ACCT} select account'
204
+ account = accounts[_menu(header, ['ACCOUNT', 'NAME'], rows, max(acct_current, 0), acct_current)]
205
+ account_id: AccountId = account['accountId']
206
+
207
+ roles: list[RoleName] = []
208
+ for page in sso.get_paginator('list_account_roles').paginate(accessToken=token, accountId=account_id):
209
+ roles.extend(r['roleName'] for r in page['roleList'])
210
+ if not roles:
211
+ raise SystemExit(f'no roles available in {account_id}')
212
+ if len(roles) == 1:
213
+ role = roles[0]
214
+ print(f'{_ROLE} only role: {role}')
215
+ else:
216
+ role_current = next((i for i, r in enumerate(roles) if r == cur_role and account_id == cur_account), -1)
217
+ header = f'{_AWS} {_ROLE} select role'
218
+ role = roles[_menu(header, ['ROLE'], [[r] for r in roles], max(role_current, 0), role_current)]
219
+ return account, role
220
+
221
+
222
+ def _write_account_role(profile: ProfileName, account: Account, role: RoleName) -> None:
223
+ _aws('configure', 'set', 'sso_account_id', account['accountId'], '--profile', profile)
224
+ _aws('configure', 'set', 'sso_role_name', role, '--profile', profile)
225
+ _aws('configure', 'set', 'sso_account_name', account['accountName'], '--profile', profile)
226
+
227
+
228
+ def cmd_switch(profile: ProfileName) -> None:
229
+ session_name, sso_region, cur_account, cur_role = _profile_sso(profile)
230
+ token = _ensure_token(profile, session_name)
231
+ account, role = _pick_account_role(sso_region, token, cur_account, cur_role)
232
+ account_id = account['accountId']
233
+ _write_account_role(profile, account, role)
234
+ print(f'\n{_ORANGE}{_CHECK}{_RESET} {profile} → {account_id} ({account["accountName"]}) / {role}')
235
+
236
+
237
+ def cmd_add(session: SessionName, new_profile: ProfileName, full_config: AwsConfig) -> None:
238
+ sso_sessions = full_config['sso_sessions']
239
+ profiles = full_config['profiles']
240
+
241
+ if session not in sso_sessions:
242
+ raise SystemExit(f"unknown sso-session '{session}' — available: {', '.join(sorted(sso_sessions))}")
243
+ if new_profile in profiles:
244
+ raise SystemExit(f"profile '{new_profile}' already exists — use `brolly switch` to repoint it")
245
+
246
+ sso_region: Region = sso_sessions[session]['sso_region']
247
+ sibling = next((p for p in profiles.values() if p.get('sso_session') == session), {})
248
+ _aws('configure', 'set', 'sso_session', session, '--profile', new_profile)
249
+ _aws('configure', 'set', 'region', sibling.get('region', sso_region), '--profile', new_profile)
250
+ _aws('configure', 'set', 'output', sibling.get('output', 'json'), '--profile', new_profile)
251
+
252
+ token = _ensure_token(new_profile, session)
253
+ account, role = _pick_account_role(sso_region, token)
254
+ account_id = account['accountId']
255
+ _write_account_role(new_profile, account, role)
256
+ print(f'\n{_ORANGE}{_CHECK}{_RESET} added {new_profile} → {account_id} ({account["accountName"]}) / {role}')
257
+ print(f'{_DIM} use it: export AWS_PROFILE={new_profile}{_RESET}')
258
+
259
+
260
+ def _backfill_account_name(profile: ProfileName, sso_region: Region, account_id: AccountId | None) -> None:
261
+ if not account_id:
262
+ return
263
+ if botocore.session.Session(profile=profile).get_scoped_config().get('sso_account_name'):
264
+ return
265
+ token = _resolve_token(profile)
266
+ if token is None:
267
+ return
268
+ sso = boto3.client('sso', region_name=sso_region)
269
+ for page in sso.get_paginator('list_accounts').paginate(accessToken=token):
270
+ for a in page['accountList']:
271
+ if a['accountId'] == account_id:
272
+ _aws('configure', 'set', 'sso_account_name', a['accountName'], '--profile', profile)
273
+ return
274
+
275
+
276
+ def cmd_refresh(target_profile: ProfileName, session: SessionName, full_config: AwsConfig) -> None:
277
+ profiles = full_config['profiles']
278
+ if target_profile not in profiles:
279
+ raise SystemExit(f"unknown profile '{target_profile}' — available: {', '.join(sorted(profiles))}")
280
+ actual: SessionName | None = profiles[target_profile].get('sso_session')
281
+ if not actual:
282
+ raise SystemExit(f"profile '{target_profile}' is not an SSO profile (no sso_session)")
283
+ if actual != session:
284
+ raise SystemExit(
285
+ f"profile '{target_profile}' is under session '{actual}', not '{session}' — use -s {actual} to target it"
286
+ )
287
+ if actual not in full_config['sso_sessions']:
288
+ raise SystemExit(f"sso-session '{actual}' referenced by '{target_profile}' is not defined")
289
+ sso_region: Region = full_config['sso_sessions'][actual]['sso_region']
290
+ account_id: AccountId | None = profiles[target_profile].get('sso_account_id')
291
+ check = subprocess.run(
292
+ ['aws', 'sts', 'get-caller-identity', '--profile', target_profile, '--query', 'Arn', '--output', 'text'],
293
+ text=True,
294
+ capture_output=True,
295
+ )
296
+ if check.returncode != 0:
297
+ print(f'{target_profile}: credentials unavailable — logging in…', file=sys.stderr)
298
+ _aws('sso', 'login', '--sso-session', actual, '--no-browser', '--use-device-code', capture=False)
299
+ arn = _aws(
300
+ 'sts', 'get-caller-identity', '--profile', target_profile, '--query', 'Arn', '--output', 'text'
301
+ ).stdout.strip()
302
+ else:
303
+ arn = check.stdout.strip()
304
+ _backfill_account_name(target_profile, sso_region, account_id)
305
+ print(f'{_ORANGE}{_CHECK}{_RESET} {target_profile} live → {arn}')
306
+
307
+
308
+ def _build_parser() -> argparse.ArgumentParser:
309
+ parser = argparse.ArgumentParser(prog='brolly', description='authenticate, repoint, or create AWS SSO profiles')
310
+ sub = parser.add_subparsers(dest='cmd')
311
+
312
+ login = sub.add_parser('login', help='force a fresh device-code login for a session')
313
+ login.add_argument('-s', '--session', help="sso-session to log into (default: current profile's)")
314
+
315
+ sub.add_parser('switch', help='pick a new account/role for $AWS_PROFILE')
316
+
317
+ refresh = sub.add_parser('refresh', help="verify/refresh a profile's credentials")
318
+ refresh.add_argument('profile', nargs='?', help='target profile (default: $AWS_PROFILE)')
319
+ refresh.add_argument('-s', '--session', help="assert the sso-session (default: current profile's)")
320
+
321
+ add = sub.add_parser('add', help='create a new profile under an sso-session')
322
+ add.add_argument('profile', help='new profile name')
323
+ add.add_argument('-s', '--session', help="session to create it under (default: current profile's)")
324
+
325
+ return parser
326
+
327
+
328
+ def _session_in_context(current: ProfileName, full_config: AwsConfig, override: SessionName | None) -> SessionName:
329
+ """The sso-session to operate under: an explicit -s override, else the current profile's; exit if neither."""
330
+ session = override or full_config['profiles'].get(current, {}).get('sso_session')
331
+ if not session:
332
+ raise SystemExit(
333
+ f"no sso-session in context — current profile '{current}' has no sso_session; pass -s <session>"
334
+ )
335
+ return session
336
+
337
+
338
+ def main(argv: list[str]) -> None:
339
+ _require_aws()
340
+ current: ProfileName = os.environ.get('AWS_PROFILE', 'default')
341
+ parser = _build_parser()
342
+ args = parser.parse_args(argv or ['refresh'])
343
+ full_config: AwsConfig = botocore.session.Session().full_config
344
+ if args.cmd == 'login':
345
+ session = _session_in_context(current, full_config, args.session)
346
+ if session not in full_config['sso_sessions']:
347
+ raise SystemExit(
348
+ f"unknown sso-session '{session}' — available: {', '.join(sorted(full_config['sso_sessions']))}"
349
+ )
350
+ cmd_login(session)
351
+ elif args.cmd == 'switch':
352
+ cmd_switch(current)
353
+ elif args.cmd == 'refresh':
354
+ target = args.profile or current
355
+ cmd_refresh(target, _session_in_context(current, full_config, args.session), full_config)
356
+ elif args.cmd == 'add':
357
+ cmd_add(_session_in_context(current, full_config, args.session), args.profile, full_config)
358
+ else:
359
+ parser.print_usage(sys.stderr)
360
+ raise SystemExit(2)
361
+
362
+
363
+ def app() -> None:
364
+ try:
365
+ main(sys.argv[1:])
366
+ except KeyboardInterrupt:
367
+ raise SystemExit(130) from None
368
+
369
+
370
+ if __name__ == '__main__':
371
+ app()
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.3
2
+ Name: brolly
3
+ Version: 0.1.0
4
+ Summary: A pure-Python CLI for AWS IAM Identity Center (SSO)
5
+ Keywords: aws,sso,iam-identity-center,cli,boto3,aws-profile
6
+ Author: Full Duplex Media
7
+ Author-email: Full Duplex Media <contact@fullduplex.media>
8
+ License: Apache-2.0
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Utilities
19
+ Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
20
+ Requires-Dist: boto3
21
+ Requires-Python: >=3.14
22
+ Project-URL: Homepage, https://github.com/fduplex/brolly
23
+ Project-URL: Repository, https://github.com/fduplex/brolly
24
+ Project-URL: Issues, https://github.com/fduplex/brolly/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # brolly
28
+
29
+ > *brolly* — British informal for umbrella. One login covers every profile under an AWS SSO session.
30
+
31
+ A small, pure-Python CLI for AWS IAM Identity Center (SSO): log in once per session, switch accounts and roles
32
+ in place, and keep your profiles fresh — without ever touching `$AWS_PROFILE`.
33
+
34
+ brolly drives AWS SSO the way the modern `[sso-session]` config was meant to be used: authenticate once against
35
+ a session and every profile that references it is usable. It refreshes and verifies credentials cheaply (the
36
+ default), forces a fresh login when you want one, repoints the current profile to a different account/role, or
37
+ adds a new profile under an existing session — and it ships a freshness-aware shell-prompt pill so you can see
38
+ your credential state at a glance.
39
+
40
+ ```
41
+ brolly verify/refresh the current profile (same as `brolly refresh`)
42
+ brolly login [-s <session>] force a fresh device-code login for a session
43
+ brolly switch repoint the current profile's account/role
44
+ brolly refresh [<profile>] [-s <session>]
45
+ brolly add <profile> [-s <session>]
46
+ ```
47
+
48
+ ## Install
49
+
50
+ ```console
51
+ $ uv tool install brolly # recommended
52
+ $ pipx install brolly
53
+ $ pip install brolly
54
+ ```
55
+
56
+ **Prerequisites:**
57
+
58
+ - **AWS CLI v2** on your `$PATH`. brolly shells out to `aws sso login` and `aws configure set`; everything else
59
+ (listing accounts/roles, resolving tokens) goes through boto3. The AWS CLI is *not* a pip dependency — install
60
+ it separately: <https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html>.
61
+ - **Python 3.14+**.
62
+ - **A Unix-like terminal.** The arrow-key picker uses `termios`/`tty`, so brolly's interactive menus need a
63
+ POSIX TTY (Linux, macOS, WSL). Off a TTY it falls back to numeric selection.
64
+
65
+ ## Mental model
66
+
67
+ Modern AWS SSO config in `~/.aws/config` has two layers:
68
+
69
+ - **`[sso-session <name>]`** — the session. Holds `sso_start_url`, `sso_region`, etc. Logging in against
70
+ a session caches ONE token, keyed by SHA1 of the session name (`~/.aws/sso/cache/<sha1>.json`).
71
+ - **`[profile <name>]`** — a profile references a session via `sso_session = <name>` and adds its own
72
+ `sso_account_id` / `sso_role_name`. Any number of profiles can point at the same session.
73
+
74
+ Because the token is cached per-session, not per-profile, **every profile under the same session shares one
75
+ login** — authenticate once and all sibling profiles are usable. (One login covering many profiles is where the
76
+ name comes from.)
77
+
78
+ `$AWS_PROFILE` is a fixed handle you set yourself in each shell. **brolly never touches it** — it only changes
79
+ what a profile resolves to (`switch`), creates new profiles (`add`), or verifies a profile's credentials
80
+ (`refresh`, which targets the `aws` CLI with `--profile` rather than mutating the ambient env). To actually use
81
+ a different profile you still `export AWS_PROFILE=<name>` yourself. There is no shell wrapper and nothing that
82
+ rewrites your environment behind your back.
83
+
84
+ ## Commands
85
+
86
+ ### `brolly`
87
+
88
+ Bare `brolly` (no subcommand) is shorthand for `brolly refresh` on the current `$AWS_PROFILE` — cheap, no
89
+ browser unless the session is actually dead.
90
+
91
+ ```console
92
+ $ brolly
93
+ ✔ corp-dev live → arn:aws:sts::111111111111:assumed-role/AdministratorAccess/alex
94
+ ```
95
+
96
+ ### `brolly login`
97
+
98
+ Forces a fresh device-code login for an sso-session, unconditionally — no token check first. Session
99
+ defaults to the current `$AWS_PROFILE`'s `sso_session`; `-s/--session` targets a different session. It's
100
+ session-scoped, not profile-scoped (no profile argument).
101
+
102
+ This is the rare escape hatch for "I'm still valid but I want a new login anyway" — `refresh` (and therefore
103
+ bare `brolly`) already logs in automatically whenever a session is actually dead, so day to day you shouldn't
104
+ need this.
105
+
106
+ ```console
107
+ $ brolly login
108
+ $ brolly login -s corp
109
+ ```
110
+
111
+ ### `brolly switch`
112
+
113
+ Interactively repoints the CURRENT `$AWS_PROFILE` to a different account/role under its own session. Arrow-key
114
+ picker (`↑`/`↓` or `j`/`k`, `enter` to select, `q`/`esc`/Ctrl-C to quit); shows the account list, then the role
115
+ list (skipped if the account has exactly one role). Current account/role are pre-selected. `sso_account_id`,
116
+ `sso_role_name`, and `sso_account_name` of that profile are rewritten in place — everything else about the
117
+ profile is untouched. Recording `sso_account_name` is what lets the shell prompt show a friendly account name
118
+ instead of the raw ID.
119
+
120
+ ```console
121
+ $ export AWS_PROFILE=corp-prod
122
+ $ brolly switch
123
+ select account ↑/↓ move · enter select · q quit
124
+ ACCOUNT NAME
125
+ 111111111111 corp-prod ← current
126
+ ▶ 222222222222 corp-staging
127
+
128
+ select role ↑/↓ move · enter select · q quit
129
+ ROLE
130
+ ▶ AdministratorAccess
131
+
132
+ ✔ corp-prod → 222222222222 (corp-staging) / AdministratorAccess
133
+ ```
134
+
135
+ ### `brolly refresh`
136
+
137
+ The cheap, no-browser daily-driver check. Takes an optional `<profile>` positional (default: `$AWS_PROFILE`)
138
+ and an optional `-s/--session <name>` that asserts which sso-session you're operating in (default: the
139
+ `sso_session` of the current `$AWS_PROFILE`). The target profile must actually belong to the asserted session,
140
+ or the command fails loudly — this makes crossing sessions always deliberate:
141
+
142
+ ```console
143
+ $ AWS_PROFILE=corp-dev brolly refresh corp-prod
144
+ ✔ corp-prod live → arn:aws:sts::222222222222:assumed-role/AdministratorAccess/alex
145
+
146
+ $ AWS_PROFILE=customer-admin brolly refresh corp-prod
147
+ profile 'corp-prod' is under session 'corp', not 'customer' — use -s corp to target it
148
+
149
+ $ AWS_PROFILE=customer-admin brolly refresh corp-prod -s corp
150
+ ✔ corp-prod live → arn:aws:sts::222222222222:assumed-role/AdministratorAccess/alex
151
+ ```
152
+
153
+ None of this ever touches `$AWS_PROFILE`: `refresh` runs `aws sts get-caller-identity --profile <target>` —
154
+ overriding the ambient env var for just that one call — and logs in with `--sso-session <session>` if needed.
155
+ Your shell's `$AWS_PROFILE` is exactly what it was before; that's the whole point of `-s` — it lets you check on
156
+ (or log into) a profile in another session without poisoning your shell.
157
+
158
+ Under the hood: `get-caller-identity` forces credential resolution and lets botocore refresh the hourly SSO
159
+ token as a side effect — but only when the token is already lapsed or within ~15 min of expiry (botocore's own
160
+ refresh window). If it still has plenty of time left, `refresh` just confirms you're authenticated without
161
+ resetting the clock — that's the intended cheap behavior. Contrast with `brolly login`, which always does a full
162
+ `aws sso login` unconditionally.
163
+
164
+ If credentials can't be resolved at all (the 7-day session is dead), it prints a notice, falls through to a
165
+ device-code `aws sso login`, and retries. It also opportunistically backfills `sso_account_name` on the target
166
+ profile if that key is missing (one `list_accounts` call, made only when absent) — so an existing profile's
167
+ prompt name heals itself the first time you refresh it.
168
+
169
+ ### `brolly add <profile> [-s <session>]`
170
+
171
+ Creates a NEW profile under an existing sso-session, walks the same account/role picker, and leaves it
172
+ authenticated and ready to use. `-s/--session` picks the session (default: the `sso_session` of the current
173
+ `$AWS_PROFILE`) — there's no cross-session guard here like `refresh` has, since the profile being written is
174
+ new:
175
+
176
+ ```console
177
+ $ brolly add corp-qa # session inferred from current $AWS_PROFILE's sso_session
178
+ $ brolly add customer-admin -s customer # explicit session
179
+ ```
180
+
181
+ What it does:
182
+
183
+ 1. Refuses if `<profile>` already exists (tells you to use `brolly switch` instead), or if `<session>` isn't a
184
+ known `sso-session` (lists the available ones).
185
+ 2. Writes a profile skeleton: `sso_session = <session>`, plus `region`/`output` copied from a sibling profile on
186
+ the same session if one exists, otherwise the session's `sso_region` and `json`.
187
+ 3. Ensures a valid token for the session, logging in if needed.
188
+ 4. Runs the account/role picker, then writes `sso_account_id` / `sso_role_name` / `sso_account_name`.
189
+
190
+ It does **not** change `$AWS_PROFILE`. To use the new profile: `export AWS_PROFILE=<name>`.
191
+
192
+ **Recovery:** if you Ctrl-C out of the picker mid-`add`, the profile skeleton (step 2) is already written to
193
+ `~/.aws/config`, so re-running `brolly add` will hit the "already exists" guard. Finish it instead:
194
+
195
+ ```console
196
+ $ export AWS_PROFILE=new-account
197
+ $ brolly switch # or `brolly` first if the token also expired
198
+ ```
199
+
200
+ ### Common tasks
201
+
202
+ | Situation | Command |
203
+ |---|---|
204
+ | Verify/refresh current creds | `brolly` |
205
+ | Force a fresh login (session healthy but you want a new one) | `brolly login` |
206
+ | Wrong account or role for the current profile | `brolly switch` |
207
+ | Need a new profile under the current session | `brolly add <name>` then `export AWS_PROFILE=<name>` |
208
+ | Need a new profile under a different session, e.g. `customer` | `brolly add <name> -s customer` |
209
+ | Refresh a profile in a different session from this shell | `brolly refresh <profile> -s <session>` |
210
+ | Interrupted a `brolly add` mid-picker | `export AWS_PROFILE=<name>` then `brolly switch` |
211
+
212
+ ## Shell prompt integration
213
+
214
+ brolly ships a drop-in `__aws_ps1` function for your `~/.bashrc` that renders a colored `AWS_PROFILE` pill
215
+ reflecting the **local, filesystem-only** state of the session token — no network call, no `aws`/boto3
216
+ invocation on every prompt:
217
+
218
+ - **live** (bright orange) — hourly token still valid.
219
+ - **idle** (grey, clock glyph) — cached but lapsed; refreshes automatically on next use.
220
+ - **gone** (red, cross glyph) — no cached token; run `brolly`.
221
+ - **plain** (neutral grey) — profile has no `sso_session` (not an SSO profile).
222
+
223
+ A dead 7-day session can't be detected locally, so it still reads as `idle` rather than `gone`. The pill also
224
+ shows the account: `<profile> · <account>`, where `<account>` is the friendly `sso_account_name` when the
225
+ profile has one, falling back to the raw `sso_account_id` if not. Names get populated by `switch`, `add`, and
226
+ `refresh`; until one of those runs against a given profile, the pill just falls back to the raw account ID.
227
+
228
+ Drop this into your `~/.bashrc` and add `$(__aws_ps1)` to your `PS1`. It needs a **[Nerd Font](https://www.nerdfonts.com/)**
229
+ for the powerline separators and glyphs.
230
+
231
+ ```bash
232
+ __aws_ps1() {
233
+ [[ -z $AWS_PROFILE ]] && return
234
+
235
+ # Cheap, filesystem-only freshness check — no aws/boto3 call, no network.
236
+ # live = hourly token still valid · idle = cached but lapsed (refreshes on next use) · gone = no token, must log in.
237
+ # The 7-day session's true death can't be known locally, so a dead session reads as "idle", not "gone".
238
+ local cfg="${AWS_CONFIG_FILE:-$HOME/.aws/config}"
239
+ local session acct cache exp now state sbg sfg glyph
240
+ IFS=$'\t' read -r session acct < <(awk -v h="[profile $AWS_PROFILE]" '
241
+ $0==h {i=1; next}
242
+ /^\[/ {i=0}
243
+ i && /^[[:space:]]*sso_session[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); s=v}
244
+ i && /^[[:space:]]*sso_account_name[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); n=v}
245
+ i && /^[[:space:]]*sso_account_id[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); a=v}
246
+ END {print s "\t" (n!=""?n:a)}' "$cfg" 2>/dev/null)
247
+
248
+ if [[ -z $session ]]; then
249
+ state=plain
250
+ else
251
+ cache="$HOME/.aws/sso/cache/$(printf %s "$session" | sha1sum | cut -c1-40).json"
252
+ if [[ ! -f $cache ]]; then
253
+ state=gone
254
+ else
255
+ exp=$(sed -n 's/.*"expiresAt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$cache" | head -1)
256
+ printf -v now '%(%s)T' -1
257
+ exp=$(date -d "$exp" +%s 2>/dev/null)
258
+ [[ -n $exp && $exp -gt $now ]] && state=live || state=idle
259
+ fi
260
+ fi
261
+
262
+ case $state in
263
+ live) sbg=214 sfg=236 glyph='' ;; # bright AWS orange — creds are live
264
+ idle) sbg=240 sfg=214 glyph=$' ' ;; # grey clock — lapsed, will refresh on next use
265
+ gone) sbg=160 sfg=231 glyph=$' ' ;; # red cross — no cached token, run brolly
266
+ plain) sbg=238 sfg=250 glyph='' ;; # non-SSO profile — neutral
267
+ esac
268
+
269
+ # powerline pill: amazon-glyph | profile ; \001/\002 wrap non-printing bytes for correct prompt-width math
270
+ local E=$'\033' A=$'\001' B=$'\002' PL=$'' AWS=$''
271
+ printf '%s%s%s%s%s' \
272
+ "${A}${E}[48;5;238;38;5;214;1m${B} ${AWS} " \
273
+ "${A}${E}[38;5;238;48;5;${sbg}m${B}${PL}" \
274
+ "${A}${E}[48;5;${sbg};38;5;${sfg};1m${B} ${glyph}${AWS_PROFILE}${acct:+ · $acct} " \
275
+ "${A}${E}[0;38;5;${sbg}m${B}${PL}" \
276
+ "${A}${E}[0m${B} "
277
+ }
278
+ ```
279
+
280
+ ## How it compares
281
+
282
+ No single existing tool combines what brolly does. Its niche is the *combination*: native to the `sso-session` block
283
+ + in-place `switch`/`add` + never mutating `$AWS_PROFILE` + a shipped freshness-aware prompt pill +
284
+ pure-Python/pip.
285
+
286
+ - **[aws-sso-util](https://github.com/benkehoe/aws-sso-util)** (Ben Kehoe) is the closest sibling — pure-Python,
287
+ config-native, and non-invasive, in the same spirit as brolly. But it has no prompt integration, no in-place
288
+ single-profile repoint, and predates the `sso-session` block.
289
+ - **[granted](https://granted.dev/)** and **[aws-sso-cli](https://github.com/synfinatic/aws-sso-cli)** are
290
+ excellent tools, but they're Go binaries that install a shell wrapper mutating your shell and generate bulk
291
+ static profiles. brolly is deliberately thinner: no wrapper, no bulk generation, in-place edits only.
292
+ - **[awsume](https://awsu.me/)** targets classic IAM role assumption, not Identity Center.
293
+
294
+ This is positioning, not disparagement — if you want OS-keychain-encrypted tokens today, reach for aws-vault,
295
+ granted, or aws-sso-cli (see caveats below and the roadmap).
296
+
297
+ ## Design notes & caveats
298
+
299
+ Two things to own up front:
300
+
301
+ - **`switch` rewrites `~/.aws/config` globally.** A profile lives in one shared config file, so switching the
302
+ account/role of a profile name silently retargets *any other shell* pinned to the **same profile name** on its
303
+ next command. Safe use = **one distinct profile name per concurrent context.** If you keep two shells on the
304
+ same account simultaneously, give them different profile names. This is the one real footgun — stated plainly.
305
+ - **Tokens live in the stock plaintext `~/.aws/sso/cache/`** — the very same cache the `aws` CLI uses. brolly
306
+ adds no encryption of its own; it's a thin layer over the stock cache by design. For OS-keychain encryption
307
+ *today*, use aws-vault / granted / aws-sso-cli. Encrypted storage is the lead roadmap item below.
308
+
309
+ ## Roadmap
310
+
311
+ 1. **Keychain-backed token storage via a `credential_process` mode.** An opt-in mode where brolly does the
312
+ device auth itself, stores the SSO token in the OS keychain (via the Python
313
+ [`keyring`](https://github.com/jaraco/keyring) library), and registers itself as each profile's
314
+ `credential_process` — so credentials never sit in plaintext on disk and SDK consumers stay fully transparent
315
+ (just `AWS_PROFILE`, no shell wrapper). The prompt pill's freshness check would then read a small non-secret
316
+ expiry sidecar instead of the plaintext cache. On headless Linux with no OS keychain, `keyring` falls back to
317
+ an encrypted-file backend guarded by a passphrase. This mode makes brolly "take over credential resolution,"
318
+ which is exactly the trade the plaintext default avoids — so it stays **opt-in**, keeping brolly a thin stock-
319
+ cache layer by default.
320
+ 2. Test suite, CI, and publish to the `fduplex` PyPI org.
321
+
322
+ ## License
323
+
324
+ [Apache-2.0](LICENSE) © 2026 Full Duplex Media
@@ -0,0 +1,7 @@
1
+ brolly/__init__.py,sha256=IMjkMO3twhQzluVTo8Z6rE7Eg-9U79_LGKMcsWLKBkY,22
2
+ brolly/__main__.py,sha256=9Kc_V51Ueo2WBH-e1AaMNtMkuZhHX7SBEuTZH4S68oM,65
3
+ brolly/cli.py,sha256=2tEhJ8DmBQNkumnGJEm_n2eHmct8fnfwOZHRQakPiCA,16679
4
+ brolly-0.1.0.dist-info/WHEEL,sha256=r-Se0i_n47Mj8pdnVuq7W628oP9YAKMaTjp4l3lQcns,81
5
+ brolly-0.1.0.dist-info/entry_points.txt,sha256=4YIDD2iDS-Ld0agrtxfQyzr9cRU4LoGxXZDBAIKaLPE,43
6
+ brolly-0.1.0.dist-info/METADATA,sha256=ZYStTMAVzdD6MfEuk8qAQ32F6TXoYL9IOn6f3Y8ePYU,16579
7
+ brolly-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.31
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ brolly = brolly.cli:app
3
+