haru-cli 0.1.2__tar.gz → 0.1.3__tar.gz

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.
Files changed (94) hide show
  1. {haru_cli-0.1.2 → haru_cli-0.1.3}/CHANGELOG.md +13 -0
  2. {haru_cli-0.1.2 → haru_cli-0.1.3}/PKG-INFO +8 -3
  3. {haru_cli-0.1.2 → haru_cli-0.1.3}/README.md +7 -2
  4. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/haru.yaml +4 -2
  5. {haru_cli-0.1.2 → haru_cli-0.1.3}/pyproject.toml +1 -1
  6. haru_cli-0.1.3/src/haru/auth/identity.py +169 -0
  7. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/session.py +24 -8
  8. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/config.py +2 -6
  9. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/login.py +24 -2
  10. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/schema.py +9 -3
  11. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/haru.yaml +4 -2
  12. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_cli.py +14 -1
  13. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_config_command.py +0 -2
  14. haru_cli-0.1.3/tests/unit/test_identity.py +172 -0
  15. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_session.py +60 -4
  16. {haru_cli-0.1.2 → haru_cli-0.1.3}/uv.lock +1 -1
  17. {haru_cli-0.1.2 → haru_cli-0.1.3}/.github/workflows/ci.yml +0 -0
  18. {haru_cli-0.1.2 → haru_cli-0.1.3}/.github/workflows/release.yml +0 -0
  19. {haru_cli-0.1.2 → haru_cli-0.1.3}/.gitignore +0 -0
  20. {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/coding-standards.md +0 -0
  21. {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/git-workflow.md +0 -0
  22. {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/product.md +0 -0
  23. {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/security.md +0 -0
  24. {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/structure.md +0 -0
  25. {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/tech.md +0 -0
  26. {haru_cli-0.1.2 → haru_cli-0.1.3}/.kiro/steering/testing.md +0 -0
  27. {haru_cli-0.1.2 → haru_cli-0.1.3}/.pre-commit-config.yaml +0 -0
  28. {haru_cli-0.1.2 → haru_cli-0.1.3}/.python-version +0 -0
  29. {haru_cli-0.1.2 → haru_cli-0.1.3}/CLAUDE.md +0 -0
  30. {haru_cli-0.1.2 → haru_cli-0.1.3}/LICENSE +0 -0
  31. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/agents.yaml +0 -0
  32. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/guardrails.yaml +0 -0
  33. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/logging.yaml +0 -0
  34. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/mcp.yaml +0 -0
  35. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/models.yaml +0 -0
  36. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/prompts/researcher.md +0 -0
  37. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/prompts/supervisor.md +0 -0
  38. {haru_cli-0.1.2 → haru_cli-0.1.3}/config/prompts/writer.md +0 -0
  39. {haru_cli-0.1.2 → haru_cli-0.1.3}/docs/configuration.md +0 -0
  40. {haru_cli-0.1.2 → haru_cli-0.1.3}/renovate.json +0 -0
  41. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/__init__.py +0 -0
  42. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/__main__.py +0 -0
  43. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/agents/__init__.py +0 -0
  44. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/agents/factory.py +0 -0
  45. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/agents/orchestration.py +0 -0
  46. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/__init__.py +0 -0
  47. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/cache.py +0 -0
  48. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/pkce.py +0 -0
  49. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/auth/sso.py +0 -0
  50. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/cli.py +0 -0
  51. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/__init__.py +0 -0
  52. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/agents.py +0 -0
  53. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/chat.py +0 -0
  54. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/run.py +0 -0
  55. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/session.py +0 -0
  56. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/commands/streaming.py +0 -0
  57. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/__init__.py +0 -0
  58. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/loader.py +0 -0
  59. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/__init__.py +0 -0
  60. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/agents.yaml +0 -0
  61. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/logging.yaml +0 -0
  62. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/mcp.yaml +0 -0
  63. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/models.yaml +0 -0
  64. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/prompts/researcher.md +0 -0
  65. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/prompts/supervisor.md +0 -0
  66. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/config/templates/prompts/writer.md +0 -0
  67. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/errors.py +0 -0
  68. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/models/__init__.py +0 -0
  69. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/models/bedrock.py +0 -0
  70. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/observability/__init__.py +0 -0
  71. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/observability/guardrails.py +0 -0
  72. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/observability/telemetry.py +0 -0
  73. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/sessions/__init__.py +0 -0
  74. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/sessions/manager.py +0 -0
  75. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/steering/__init__.py +0 -0
  76. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/steering/prompts.py +0 -0
  77. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/tools/__init__.py +0 -0
  78. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/tools/mcp.py +0 -0
  79. {haru_cli-0.1.2 → haru_cli-0.1.3}/src/haru/tools/registry.py +0 -0
  80. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/conftest.py +0 -0
  81. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/integration/test_packaging.py +0 -0
  82. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_agent_factory.py +0 -0
  83. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_bedrock.py +0 -0
  84. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_cache.py +0 -0
  85. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_config_loader.py +0 -0
  86. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_guardrails.py +0 -0
  87. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_main.py +0 -0
  88. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_mcp.py +0 -0
  89. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_orchestration.py +0 -0
  90. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_pkce.py +0 -0
  91. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_prompts.py +0 -0
  92. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_sessions.py +0 -0
  93. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_sso.py +0 -0
  94. {haru_cli-0.1.2 → haru_cli-0.1.3}/tests/unit/test_telemetry.py +0 -0
@@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - Browser-only login: `haru login` now discovers your accessible AWS
13
+ accounts and permission-set roles from Identity Center after sign-in
14
+ (auto-selected when unambiguous, prompted otherwise) and remembers the
15
+ choice — no IAM role name or account id env var needed upfront.
16
+ `auth.sso.account_id`/`role_name` remain available as optional pins.
17
+
18
+ ### Changed
19
+
20
+ - `haru config init` no longer asks for an IAM role name; the
21
+ `HARU_AWS_ACCOUNT_ID` step is gone from the quickstart.
22
+
23
+ ### Added
24
+
12
25
  - Sampling controls (`temperature`, `top_p`, `top_k`, `seed`) across three
13
26
  surfaces: model entries, per-agent `sampling:` blocks, and CLI/REPL
14
27
  overrides (`--temperature/--top-p/--top-k/--seed`, `/sampling`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haru-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Secure, scriptable CLI for Amazon Bedrock Claude models via the AWS Strands Agents SDK.
5
5
  Author: haru-cli maintainers
6
6
  License-Expression: Apache-2.0
@@ -84,12 +84,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
84
84
 
85
85
  ```bash
86
86
  haru config init # writes ~/.config/haru (asks for your SSO start URL)
87
- export HARU_AWS_ACCOUNT_ID=<your AWS account id>
88
- haru login
87
+ haru login # browser sign-in; account and role discovered for you
89
88
  haru run "Summarise the Converse API in two sentences."
90
89
  haru chat --agent supervisor --session-id demo
91
90
  ```
92
91
 
92
+ `haru login` is browser-only: after Identity Center sign-in, your accessible
93
+ AWS accounts and permission-set roles are discovered automatically (like
94
+ `aws configure sso`), auto-selected when there is exactly one, prompted
95
+ otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
96
+ `role_name` in configuration only if you want to skip the discovery.
97
+
93
98
  Authentication uses the same PKCE authorization-code flow as `aws sso login`,
94
99
  caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
95
100
  (0600) so standard AWS tooling can consume and refresh the same cache. Access
@@ -56,12 +56,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
56
56
 
57
57
  ```bash
58
58
  haru config init # writes ~/.config/haru (asks for your SSO start URL)
59
- export HARU_AWS_ACCOUNT_ID=<your AWS account id>
60
- haru login
59
+ haru login # browser sign-in; account and role discovered for you
61
60
  haru run "Summarise the Converse API in two sentences."
62
61
  haru chat --agent supervisor --session-id demo
63
62
  ```
64
63
 
64
+ `haru login` is browser-only: after Identity Center sign-in, your accessible
65
+ AWS accounts and permission-set roles are discovered automatically (like
66
+ `aws configure sso`), auto-selected when there is exactly one, prompted
67
+ otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
68
+ `role_name` in configuration only if you want to skip the discovery.
69
+
65
70
  Authentication uses the same PKCE authorization-code flow as `aws sso login`,
66
71
  caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
67
72
  (0600) so standard AWS tooling can consume and refresh the same cache. Access
@@ -8,8 +8,10 @@ auth:
8
8
  registration_scopes:
9
9
  - sso:account:access
10
10
  client_name: haru-cli
11
- account_id_env: HARU_AWS_ACCOUNT_ID
12
- role_name: HaruBedrockInvoke
11
+ # Optional pins. When unset, your AWS account and role are discovered
12
+ # during 'haru login' (browser sign-in) and remembered.
13
+ # account_id: "123456789012"
14
+ # role_name: HaruBedrockInvoke
13
15
  callback_port: 0 # 0 selects an ephemeral loopback port
14
16
  browser: true
15
17
  bedrock_region: us-east-1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "haru-cli"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Secure, scriptable CLI for Amazon Bedrock Claude models via the AWS Strands Agents SDK."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,169 @@
1
+ """Discover and persist the AWS account and role behind an SSO sign-in.
2
+
3
+ Kiro-style browser sign-in needs no upfront AWS knowledge: after the token
4
+ exchange, the accessible accounts and permission-set roles are listed from
5
+ IAM Identity Center (as ``aws configure sso`` does), auto-selected when there
6
+ is exactly one, and persisted next to the token cache for later sessions.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import boto3
17
+ from botocore.exceptions import ClientError
18
+
19
+ from haru.auth.cache import cache_path
20
+ from haru.auth.sso import SsoToken
21
+ from haru.config.schema import SsoConfig
22
+ from haru.errors import AuthError
23
+
24
+ Chooser = Callable[[str, list[str]], int]
25
+ """Callback picking one option: (question, labels) -> chosen index."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SelectedIdentity:
30
+ """The AWS account and role selected for Bedrock access."""
31
+
32
+ account_id: str
33
+ role_name: str
34
+ account_name: str | None = None
35
+
36
+
37
+ def identity_path(start_url: str, cache_dir: Path | None = None) -> Path:
38
+ """Return the persisted-identity path for ``start_url``."""
39
+ token_path = cache_path(start_url, cache_dir)
40
+ return token_path.with_name(f"{token_path.stem}-haru-identity.json")
41
+
42
+
43
+ def write_identity(
44
+ identity: SelectedIdentity, start_url: str, cache_dir: Path | None = None
45
+ ) -> Path:
46
+ """Persist the selected identity next to the token cache."""
47
+ path = identity_path(start_url, cache_dir)
48
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
49
+ payload = {
50
+ "accountId": identity.account_id,
51
+ "roleName": identity.role_name,
52
+ "accountName": identity.account_name,
53
+ }
54
+ path.write_text(json.dumps(payload), encoding="utf-8")
55
+ path.chmod(0o600)
56
+ return path
57
+
58
+
59
+ def read_identity(start_url: str, cache_dir: Path | None = None) -> SelectedIdentity | None:
60
+ """Read the persisted identity; None when absent or unreadable."""
61
+ path = identity_path(start_url, cache_dir)
62
+ if not path.is_file():
63
+ return None
64
+ try:
65
+ payload = json.loads(path.read_text(encoding="utf-8"))
66
+ return SelectedIdentity(
67
+ account_id=payload["accountId"],
68
+ role_name=payload["roleName"],
69
+ account_name=payload.get("accountName"),
70
+ )
71
+ except (json.JSONDecodeError, KeyError):
72
+ return None
73
+
74
+
75
+ def select_identity(
76
+ sso_cfg: SsoConfig,
77
+ token: SsoToken,
78
+ *,
79
+ sso_client: Any | None = None,
80
+ cache_dir: Path | None = None,
81
+ chooser: Chooser | None = None,
82
+ ) -> SelectedIdentity:
83
+ """Resolve the account and role for this sign-in and persist the choice.
84
+
85
+ Configuration pins win (``account_id``/``role_name``, or the account id
86
+ from ``account_id_env``); anything unpinned is discovered from Identity
87
+ Center. With multiple choices and no ``chooser``, raises AuthError.
88
+ """
89
+ if sso_client is None:
90
+ sso_client = boto3.Session().client("sso", region_name=sso_cfg.sso_region)
91
+
92
+ account_id = _pinned_account(sso_cfg)
93
+ account_name: str | None = None
94
+ if account_id is None:
95
+ accounts = _list_accounts(sso_client, token.access_token)
96
+ chosen = _choose(
97
+ "Choose the AWS account to use",
98
+ accounts,
99
+ lambda account: f"{account['accountId']} {account.get('accountName', '')}".strip(),
100
+ chooser,
101
+ )
102
+ account_id = chosen["accountId"]
103
+ account_name = chosen.get("accountName")
104
+
105
+ role_name = sso_cfg.role_name
106
+ if role_name is None:
107
+ roles = _list_roles(sso_client, token.access_token, account_id)
108
+ role_name = _choose(f"Choose the role for account {account_id}", roles, str, chooser)
109
+
110
+ identity = SelectedIdentity(
111
+ account_id=account_id, role_name=role_name, account_name=account_name
112
+ )
113
+ write_identity(identity, sso_cfg.start_url, cache_dir)
114
+ return identity
115
+
116
+
117
+ def _pinned_account(sso_cfg: SsoConfig) -> str | None:
118
+ if sso_cfg.account_id is not None:
119
+ return sso_cfg.account_id
120
+ if sso_cfg.account_id_env is not None:
121
+ return os.environ.get(sso_cfg.account_id_env)
122
+ return None
123
+
124
+
125
+ def _list_accounts(sso_client: Any, access_token: str) -> list[dict[str, Any]]:
126
+ try:
127
+ paginator = sso_client.get_paginator("list_accounts")
128
+ accounts = [
129
+ account
130
+ for page in paginator.paginate(accessToken=access_token)
131
+ for account in page.get("accountList", [])
132
+ ]
133
+ except ClientError as exc:
134
+ raise AuthError(f"Listing accessible AWS accounts failed: {exc}") from exc
135
+ if not accounts:
136
+ raise AuthError("This sign-in grants access to no AWS accounts; contact your admin")
137
+ return sorted(accounts, key=lambda account: str(account["accountId"]))
138
+
139
+
140
+ def _list_roles(sso_client: Any, access_token: str, account_id: str) -> list[str]:
141
+ try:
142
+ paginator = sso_client.get_paginator("list_account_roles")
143
+ roles = [
144
+ role["roleName"]
145
+ for page in paginator.paginate(accessToken=access_token, accountId=account_id)
146
+ for role in page.get("roleList", [])
147
+ ]
148
+ except ClientError as exc:
149
+ raise AuthError(f"Listing roles for account {account_id} failed: {exc}") from exc
150
+ if not roles:
151
+ raise AuthError(f"No roles are available in account {account_id}; contact your admin")
152
+ return sorted(roles)
153
+
154
+
155
+ def _choose[Item](
156
+ question: str,
157
+ options: list[Item],
158
+ label: Callable[[Item], str],
159
+ chooser: Chooser | None,
160
+ ) -> Item:
161
+ if len(options) == 1:
162
+ return options[0]
163
+ if chooser is None:
164
+ raise AuthError(
165
+ f"{question}: multiple options available; re-run 'haru login' interactively"
166
+ " or pin the choice in configuration"
167
+ )
168
+ index = chooser(question, [label(option) for option in options])
169
+ return options[index]
@@ -14,9 +14,10 @@ from botocore.exceptions import (
14
14
  )
15
15
 
16
16
  from haru.auth.cache import read_token_cache, write_token_cache
17
+ from haru.auth.identity import read_identity
17
18
  from haru.auth.sso import SsoToken
18
- from haru.config.schema import AuthConfig
19
- from haru.errors import AuthExpiredError, ConfigError
19
+ from haru.config.schema import AuthConfig, SsoConfig
20
+ from haru.errors import AuthExpiredError
20
21
 
21
22
  _REFRESH_WINDOW = timedelta(minutes=15)
22
23
  _RELOGIN_HINT = "run 'haru login'"
@@ -42,16 +43,12 @@ def build_boto3_session(
42
43
  if token.expires_at <= datetime.now(UTC) + _REFRESH_WINDOW:
43
44
  token = _refresh_token(config, token, oidc=oidc, cache_dir=cache_dir)
44
45
 
45
- account_id = os.environ.get(sso.account_id_env)
46
- if account_id is None:
47
- raise ConfigError(
48
- f"Environment variable {sso.account_id_env!r} must hold the target AWS account id"
49
- )
46
+ account_id, role_name = _resolve_identity(sso, cache_dir)
50
47
  if sso_client is None:
51
48
  sso_client = boto3.Session().client("sso", region_name=sso.sso_region)
52
49
  try:
53
50
  response = sso_client.get_role_credentials(
54
- roleName=sso.role_name, accountId=account_id, accessToken=token.access_token
51
+ roleName=role_name, accountId=account_id, accessToken=token.access_token
55
52
  )
56
53
  except (ClientError, SSOTokenLoadError, UnauthorizedSSOTokenError, TokenRetrievalError) as exc:
57
54
  raise AuthExpiredError(f"SSO credentials rejected; {_RELOGIN_HINT}") from exc
@@ -65,6 +62,25 @@ def build_boto3_session(
65
62
  )
66
63
 
67
64
 
65
+ def _resolve_identity(sso: SsoConfig, cache_dir: Path | None) -> tuple[str, str]:
66
+ """Resolve the account id and role: config pins, env var, then the login choice."""
67
+ stored = read_identity(sso.start_url, cache_dir)
68
+
69
+ account_id = sso.account_id
70
+ if account_id is None and sso.account_id_env is not None:
71
+ account_id = os.environ.get(sso.account_id_env)
72
+ if account_id is None and stored is not None:
73
+ account_id = stored.account_id
74
+
75
+ role_name = sso.role_name
76
+ if role_name is None and stored is not None:
77
+ role_name = stored.role_name
78
+
79
+ if account_id is None or role_name is None:
80
+ raise AuthExpiredError(f"No AWS account/role selected yet; {_RELOGIN_HINT}")
81
+ return account_id, role_name
82
+
83
+
68
84
  def _refresh_token(
69
85
  config: AuthConfig, token: SsoToken, *, oidc: Any | None, cache_dir: Path | None
70
86
  ) -> SsoToken:
@@ -65,7 +65,6 @@ def config() -> None:
65
65
  "--sso-region", default="us-east-1", prompt="Identity Center region", show_default=True
66
66
  )
67
67
  @click.option("--bedrock-region", default="us-east-1", prompt="Bedrock region", show_default=True)
68
- @click.option("--role-name", default="HaruBedrockInvoke", prompt="IAM role name", show_default=True)
69
68
  @click.option(
70
69
  "--guardrail-id",
71
70
  default="",
@@ -78,7 +77,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
78
77
  start_url: str,
79
78
  sso_region: str,
80
79
  bedrock_region: str,
81
- role_name: str,
82
80
  guardrail_id: str,
83
81
  force: bool,
84
82
  ) -> None:
@@ -92,7 +90,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
92
90
  "__START_URL__": start_url,
93
91
  "__SSO_REGION__": sso_region,
94
92
  "__BEDROCK_REGION__": bedrock_region,
95
- "__ROLE_NAME__": role_name,
96
93
  }
97
94
  templates = resources.files("haru.config.templates")
98
95
  (target / "prompts").mkdir(parents=True, exist_ok=True)
@@ -113,9 +110,8 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
113
110
 
114
111
  click.echo(f"Configuration written to {target}.")
115
112
  click.echo("Next steps:")
116
- click.echo(" 1. export HARU_AWS_ACCOUNT_ID=<your AWS account id>")
117
- click.echo(" 2. haru login")
118
- click.echo(" 3. haru chat")
113
+ click.echo(" 1. haru login (browser sign-in; your account and role are discovered)")
114
+ click.echo(" 2. haru chat")
119
115
 
120
116
 
121
117
  @config.command()
@@ -1,4 +1,9 @@
1
- """The ``haru login`` command: interactive IAM Identity Center sign-in."""
1
+ """The ``haru login`` command: interactive IAM Identity Center sign-in.
2
+
3
+ Browser-only sign-in: no IAM role or account id needs to be known upfront.
4
+ After the token exchange, the accessible accounts and roles are discovered
5
+ from Identity Center, auto-selected when unambiguous, and remembered.
6
+ """
2
7
 
3
8
  import webbrowser
4
9
  from pathlib import Path
@@ -6,18 +11,28 @@ from pathlib import Path
6
11
  import click
7
12
 
8
13
  from haru.auth.cache import write_token_cache
14
+ from haru.auth.identity import select_identity
9
15
  from haru.auth.sso import run_login
10
16
  from haru.config import load_config
11
17
  from haru.errors import HaruError
12
18
 
13
19
 
20
+ def _prompt_choice(question: str, labels: list[str]) -> int:
21
+ """Interactive numbered chooser for accounts and roles."""
22
+ click.echo(f"{question}:")
23
+ for number, label in enumerate(labels, start=1):
24
+ click.echo(f" {number}. {label}")
25
+ choice: int = click.prompt("Enter a number", type=click.IntRange(1, len(labels)))
26
+ return choice - 1
27
+
28
+
14
29
  @click.command()
15
30
  @click.option(
16
31
  "--config",
17
32
  "config_path",
18
33
  type=click.Path(exists=True, dir_okay=False, path_type=Path),
19
34
  default=None,
20
- help="Path to haru.yaml (default: config/haru.yaml).",
35
+ help="Path to haru.yaml (default: ./config/haru.yaml, then ~/.config/haru/haru.yaml).",
21
36
  )
22
37
  def login(config_path: Path | None) -> None:
23
38
  """Sign in to AWS IAM Identity Center via the browser (PKCE)."""
@@ -33,6 +48,13 @@ def login(config_path: Path | None) -> None:
33
48
 
34
49
  token = run_login(auth, opener=_open)
35
50
  path = write_token_cache(token, auth.sso.start_url, auth.sso.sso_region)
51
+ identity = select_identity(auth.sso, token, chooser=_prompt_choice)
36
52
  except HaruError as exc:
37
53
  raise click.ClickException(str(exc)) from exc
38
54
  click.echo(f"Login successful. Token cached at {path}.")
55
+ account_label = (
56
+ f"{identity.account_id} ({identity.account_name})"
57
+ if identity.account_name
58
+ else identity.account_id
59
+ )
60
+ click.echo(f"Using account {account_label}, role {identity.role_name}.")
@@ -24,14 +24,20 @@ class AppConfig(_FrozenModel):
24
24
 
25
25
 
26
26
  class SsoConfig(_FrozenModel):
27
- """IAM Identity Center (SSO) login settings."""
27
+ """IAM Identity Center (SSO) login settings.
28
+
29
+ ``account_id`` and ``role_name`` are optional: when unset, the account
30
+ and permission-set role are discovered interactively during
31
+ ``haru login`` (browser-only sign-in, like the AWS CLI's SSO flow).
32
+ """
28
33
 
29
34
  start_url: str
30
35
  sso_region: str
31
36
  registration_scopes: tuple[str, ...] = ("sso:account:access",)
32
37
  client_name: str = "haru-cli"
33
- account_id_env: str
34
- role_name: str
38
+ account_id: str | None = None
39
+ account_id_env: str | None = None
40
+ role_name: str | None = None
35
41
  callback_port: int = Field(default=0, ge=0, le=65535)
36
42
  browser: bool = True
37
43
 
@@ -8,8 +8,10 @@ auth:
8
8
  registration_scopes:
9
9
  - sso:account:access
10
10
  client_name: haru-cli
11
- account_id_env: HARU_AWS_ACCOUNT_ID
12
- role_name: __ROLE_NAME__
11
+ # Optional pins. When unset, your AWS account and role are discovered
12
+ # during 'haru login' (browser sign-in) and remembered.
13
+ # account_id: "123456789012"
14
+ # role_name: BedrockAccess
13
15
  callback_port: 0 # 0 selects an ephemeral loopback port
14
16
  browser: true
15
17
  bedrock_region: __BEDROCK_REGION__
@@ -10,6 +10,7 @@ from typing import Any
10
10
  from click.testing import CliRunner
11
11
  from rich.console import Console
12
12
 
13
+ from haru.auth.identity import SelectedIdentity
13
14
  from haru.auth.sso import ClientRegistration, SsoToken
14
15
  from haru.cli import cli
15
16
  from haru.commands.chat import run_chat
@@ -81,15 +82,23 @@ def test_login_command(runner: CliRunner, tmp_path: Path, mocker: Any) -> None:
81
82
  write_cache = mocker.patch(
82
83
  "haru.commands.login.write_token_cache", return_value=tmp_path / "cache.json"
83
84
  )
85
+ select = mocker.patch(
86
+ "haru.commands.login.select_identity",
87
+ return_value=SelectedIdentity(
88
+ account_id="111122223333", role_name="BedrockAccess", account_name="Sandbox"
89
+ ),
90
+ )
84
91
 
85
92
  result = runner.invoke(cli, ["login", "--config", str(config_path)])
86
93
 
87
- assert result.exit_code == 0
94
+ assert result.exit_code == 0, result.output
88
95
  assert "Login successful" in result.output
89
96
  assert "access-abc" not in result.output
97
+ assert "Using account 111122223333 (Sandbox), role BedrockAccess." in result.output
90
98
  auth = run_login.call_args.args[0]
91
99
  assert auth.sso.start_url == "https://example.awsapps.com/start"
92
100
  write_cache.assert_called_once_with(token, auth.sso.start_url, "us-east-1")
101
+ assert select.call_args.args[0] == auth.sso
93
102
 
94
103
 
95
104
  def make_config(tmp_path: Path) -> Path:
@@ -429,6 +438,10 @@ def test_login_opener_echoes_url_and_opens_browser(
429
438
  config_path.write_text(BASE_CONFIG, encoding="utf-8")
430
439
  browser_open = mocker.patch("haru.commands.login.webbrowser.open")
431
440
  mocker.patch("haru.commands.login.write_token_cache", return_value=tmp_path / "cache.json")
441
+ mocker.patch(
442
+ "haru.commands.login.select_identity",
443
+ return_value=SelectedIdentity(account_id="111122223333", role_name="BedrockAccess"),
444
+ )
432
445
 
433
446
  def fake_run_login(auth: Any, *, opener: Any) -> Any:
434
447
  opener("https://oidc.us-east-1.amazonaws.com/authorize?x=1")
@@ -17,8 +17,6 @@ INIT_ARGS = [
17
17
  "us-east-1",
18
18
  "--bedrock-region",
19
19
  "eu-west-1",
20
- "--role-name",
21
- "HaruBedrockInvoke",
22
20
  ]
23
21
 
24
22
 
@@ -0,0 +1,172 @@
1
+ """Tests for account/role discovery and persistence after SSO sign-in."""
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pytest
8
+ from botocore.exceptions import ClientError
9
+
10
+ from haru.auth.identity import (
11
+ SelectedIdentity,
12
+ read_identity,
13
+ select_identity,
14
+ write_identity,
15
+ )
16
+ from haru.auth.sso import ClientRegistration, SsoToken
17
+ from haru.config.schema import SsoConfig
18
+ from haru.errors import AuthError
19
+
20
+ START_URL = "https://example.awsapps.com/start"
21
+
22
+
23
+ def make_sso_config(**overrides: Any) -> SsoConfig:
24
+ """Build an SsoConfig for tests."""
25
+ return SsoConfig.model_validate(
26
+ {"start_url": START_URL, "sso_region": "us-east-1", **overrides}
27
+ )
28
+
29
+
30
+ def make_token() -> SsoToken:
31
+ """Build a valid SsoToken for tests."""
32
+ now = datetime.now(UTC)
33
+ return SsoToken(
34
+ access_token="access-abc",
35
+ refresh_token=None,
36
+ expires_at=now + timedelta(hours=1),
37
+ registration=ClientRegistration(
38
+ client_id="client-123", client_secret="client-secret", expires_at=now
39
+ ),
40
+ )
41
+
42
+
43
+ class FakeSso:
44
+ """Minimal sso client stand-in with paginators."""
45
+
46
+ def __init__(self, accounts: list[dict[str, Any]], roles: dict[str, list[str]]) -> None:
47
+ self._accounts = accounts
48
+ self._roles = roles
49
+ self.role_calls: list[str] = []
50
+
51
+ def get_paginator(self, operation: str) -> Any:
52
+ fake = self
53
+
54
+ class _Paginator:
55
+ def paginate(self, **kwargs: Any) -> list[dict[str, Any]]:
56
+ if operation == "list_accounts":
57
+ return [{"accountList": fake._accounts}]
58
+ fake.role_calls.append(kwargs["accountId"])
59
+ roles = fake._roles.get(kwargs["accountId"], [])
60
+ return [{"roleList": [{"roleName": name} for name in roles]}]
61
+
62
+ return _Paginator()
63
+
64
+
65
+ def test_identity_roundtrip(tmp_path: Path) -> None:
66
+ """A written identity reads back identically, with 0600 permissions."""
67
+ identity = SelectedIdentity(account_id="111122223333", role_name="Dev", account_name="Sandbox")
68
+ path = write_identity(identity, START_URL, tmp_path)
69
+ assert path.stat().st_mode & 0o777 == 0o600
70
+ assert read_identity(START_URL, tmp_path) == identity
71
+
72
+
73
+ def test_read_missing_identity(tmp_path: Path) -> None:
74
+ """A missing identity file reads as None."""
75
+ assert read_identity(START_URL, tmp_path) is None
76
+
77
+
78
+ def test_single_account_and_role_auto_selected(tmp_path: Path) -> None:
79
+ """One account with one role needs no interaction and is persisted."""
80
+ sso = FakeSso(
81
+ [{"accountId": "111122223333", "accountName": "Sandbox"}],
82
+ {"111122223333": ["BedrockAccess"]},
83
+ )
84
+
85
+ identity = select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
86
+
87
+ assert identity == SelectedIdentity(
88
+ account_id="111122223333", role_name="BedrockAccess", account_name="Sandbox"
89
+ )
90
+ assert read_identity(START_URL, tmp_path) == identity
91
+
92
+
93
+ def test_multiple_accounts_use_chooser(tmp_path: Path) -> None:
94
+ """The chooser picks among multiple accounts and roles."""
95
+ sso = FakeSso(
96
+ [
97
+ {"accountId": "111122223333", "accountName": "Sandbox"},
98
+ {"accountId": "444455556666", "accountName": "Prod"},
99
+ ],
100
+ {"444455556666": ["Admin", "ReadOnly"]},
101
+ )
102
+ questions: list[tuple[str, list[str]]] = []
103
+
104
+ def choose_last(question: str, labels: list[str]) -> int:
105
+ questions.append((question, labels))
106
+ return len(labels) - 1
107
+
108
+ identity = select_identity(
109
+ make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path, chooser=choose_last
110
+ )
111
+
112
+ assert identity.account_id == "444455556666"
113
+ assert identity.role_name == "ReadOnly"
114
+ assert len(questions) == 2
115
+ assert "444455556666" in questions[0][1][1]
116
+
117
+
118
+ def test_multiple_accounts_without_chooser_raises(tmp_path: Path) -> None:
119
+ """Multiple options with no chooser fail with guidance."""
120
+ sso = FakeSso(
121
+ [{"accountId": "111122223333"}, {"accountId": "444455556666"}],
122
+ {},
123
+ )
124
+ with pytest.raises(AuthError, match="multiple options"):
125
+ select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
126
+
127
+
128
+ def test_pinned_account_skips_account_listing(tmp_path: Path) -> None:
129
+ """A configured account_id goes straight to role discovery."""
130
+ sso = FakeSso([], {"999900001111": ["BedrockAccess"]})
131
+
132
+ identity = select_identity(
133
+ make_sso_config(account_id="999900001111"),
134
+ make_token(),
135
+ sso_client=sso,
136
+ cache_dir=tmp_path,
137
+ )
138
+
139
+ assert identity.account_id == "999900001111"
140
+ assert sso.role_calls == ["999900001111"]
141
+
142
+
143
+ def test_env_pinned_account(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
144
+ """account_id_env still pins the account when set."""
145
+ monkeypatch.setenv("HARU_AWS_ACCOUNT_ID", "999900001111")
146
+ sso = FakeSso([], {"999900001111": ["BedrockAccess"]})
147
+
148
+ identity = select_identity(
149
+ make_sso_config(account_id_env="HARU_AWS_ACCOUNT_ID"),
150
+ make_token(),
151
+ sso_client=sso,
152
+ cache_dir=tmp_path,
153
+ )
154
+
155
+ assert identity.account_id == "999900001111"
156
+
157
+
158
+ def test_no_accounts_raises(tmp_path: Path) -> None:
159
+ """A sign-in granting no accounts fails with a clear message."""
160
+ sso = FakeSso([], {})
161
+ with pytest.raises(AuthError, match="no AWS accounts"):
162
+ select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
163
+
164
+
165
+ def test_listing_failure_wrapped(tmp_path: Path, mocker: Any) -> None:
166
+ """botocore errors surface as AuthError."""
167
+ sso = mocker.Mock()
168
+ sso.get_paginator.return_value.paginate.side_effect = ClientError(
169
+ {"Error": {"Code": "UnauthorizedException", "Message": "no"}}, "ListAccounts"
170
+ )
171
+ with pytest.raises(AuthError, match="Listing accessible AWS accounts failed"):
172
+ select_identity(make_sso_config(), make_token(), sso_client=sso, cache_dir=tmp_path)
@@ -8,10 +8,11 @@ import pytest
8
8
  from botocore.exceptions import ClientError
9
9
 
10
10
  from haru.auth.cache import read_token_cache, write_token_cache
11
+ from haru.auth.identity import SelectedIdentity, write_identity
11
12
  from haru.auth.session import build_boto3_session
12
13
  from haru.auth.sso import ClientRegistration, SsoToken
13
14
  from haru.config.schema import AuthConfig
14
- from haru.errors import AuthExpiredError, ConfigError
15
+ from haru.errors import AuthExpiredError
15
16
 
16
17
  START_URL = "https://example.awsapps.com/start"
17
18
 
@@ -168,9 +169,64 @@ def test_unauthorized_role_credentials_raises(tmp_path: Path, mocker: Any) -> No
168
169
  build_boto3_session(make_config(), sso_client=sso_client, cache_dir=tmp_path)
169
170
 
170
171
 
171
- def test_missing_account_env_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
172
- """A missing account id environment variable is a configuration error."""
172
+ def test_no_identity_anywhere_requires_login(
173
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
174
+ ) -> None:
175
+ """No pin, no env var, no stored identity: re-login is required."""
173
176
  monkeypatch.delenv("HARU_AWS_ACCOUNT_ID", raising=False)
174
177
  seed_cache(tmp_path)
175
- with pytest.raises(ConfigError, match="HARU_AWS_ACCOUNT_ID"):
178
+ with pytest.raises(AuthExpiredError, match="haru login"):
176
179
  build_boto3_session(make_config(), cache_dir=tmp_path)
180
+
181
+
182
+ def test_stored_identity_used_when_unpinned(tmp_path: Path, mocker: Any) -> None:
183
+ """The identity chosen at login backs the session when config pins nothing."""
184
+ seed_cache(tmp_path)
185
+ write_identity(
186
+ SelectedIdentity(account_id="777788889999", role_name="StoredRole"),
187
+ START_URL,
188
+ tmp_path,
189
+ )
190
+ sso_client = mocker.Mock()
191
+ sso_client.get_role_credentials.return_value = ROLE_CREDENTIALS
192
+ config = AuthConfig.model_validate(
193
+ {
194
+ "sso": {"start_url": START_URL, "sso_region": "us-east-1"},
195
+ "bedrock_region": "eu-west-1",
196
+ }
197
+ )
198
+
199
+ build_boto3_session(config, sso_client=sso_client, cache_dir=tmp_path)
200
+
201
+ sso_client.get_role_credentials.assert_called_once_with(
202
+ roleName="StoredRole", accountId="777788889999", accessToken="access-abc"
203
+ )
204
+
205
+
206
+ def test_config_pins_beat_stored_identity(tmp_path: Path, mocker: Any) -> None:
207
+ """Explicit config account/role win over the stored login choice."""
208
+ seed_cache(tmp_path)
209
+ write_identity(
210
+ SelectedIdentity(account_id="777788889999", role_name="StoredRole"),
211
+ START_URL,
212
+ tmp_path,
213
+ )
214
+ sso_client = mocker.Mock()
215
+ sso_client.get_role_credentials.return_value = ROLE_CREDENTIALS
216
+ config = AuthConfig.model_validate(
217
+ {
218
+ "sso": {
219
+ "start_url": START_URL,
220
+ "sso_region": "us-east-1",
221
+ "account_id": "123456789012",
222
+ "role_name": "PinnedRole",
223
+ },
224
+ "bedrock_region": "eu-west-1",
225
+ }
226
+ )
227
+
228
+ build_boto3_session(config, sso_client=sso_client, cache_dir=tmp_path)
229
+
230
+ sso_client.get_role_credentials.assert_called_once_with(
231
+ roleName="PinnedRole", accountId="123456789012", accessToken="access-abc"
232
+ )
@@ -633,7 +633,7 @@ wheels = [
633
633
 
634
634
  [[package]]
635
635
  name = "haru-cli"
636
- version = "0.1.2"
636
+ version = "0.1.3"
637
637
  source = { editable = "." }
638
638
  dependencies = [
639
639
  { name = "boto3" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes