honcho-cli 0.1.1__tar.gz → 0.1.2__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 (33) hide show
  1. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/.gitignore +3 -0
  2. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/CHANGELOG.md +6 -0
  3. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/PKG-INFO +1 -1
  4. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/pyproject.toml +1 -1
  5. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/__init__.py +1 -1
  6. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/setup.py +160 -14
  7. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/common.py +48 -1
  8. honcho_cli-0.1.2/src/honcho_cli/config.py +292 -0
  9. honcho_cli-0.1.2/src/honcho_cli/oauth.py +260 -0
  10. honcho_cli-0.1.2/tests/conftest.py +21 -0
  11. honcho_cli-0.1.2/tests/test_common.py +126 -0
  12. honcho_cli-0.1.2/tests/test_config.py +266 -0
  13. honcho_cli-0.1.2/tests/test_oauth.py +232 -0
  14. honcho_cli-0.1.1/src/honcho_cli/config.py +0 -150
  15. honcho_cli-0.1.1/tests/test_config.py +0 -113
  16. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/README.md +0 -0
  17. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/scripts/generate_cli_docs.py +0 -0
  18. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/_help.py +0 -0
  19. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/branding.py +0 -0
  20. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/__init__.py +0 -0
  21. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/conclusion.py +0 -0
  22. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/config_cmd.py +0 -0
  23. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/message.py +0 -0
  24. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/peer.py +0 -0
  25. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/session.py +0 -0
  26. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/workspace.py +0 -0
  27. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/main.py +0 -0
  28. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/output.py +0 -0
  29. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/validation.py +0 -0
  30. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/tests/__init__.py +0 -0
  31. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/tests/test_commands.py +0 -0
  32. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/tests/test_validation.py +0 -0
  33. {honcho_cli-0.1.1 → honcho_cli-0.1.2}/uv.lock +0 -0
@@ -193,3 +193,6 @@ metrics.jsonl
193
193
  AGENTS.md
194
194
  lancedb_data/
195
195
  grafana-data/
196
+
197
+ # Claude Code addon stuff
198
+ .omc
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
+ ## [0.1.2] - 2026-07-20
9
+
10
+ ### Added
11
+
12
+ - Device-code OAuth login for managed Honcho servers. `honcho init` now offers browser-based login (RFC 8628 device authorization grant) when the host advertises the device grant in its OAuth authorization-server metadata; tokens are persisted to `~/.honcho/config.json` and auto-refreshed (#891)
13
+
8
14
  ## [0.1.1] - 2026-06-15
9
15
 
10
16
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: honcho-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: A terminal for Honcho — memory that reasons.
5
5
  Project-URL: Homepage, https://github.com/plastic-labs/honcho
6
6
  Project-URL: Repository, https://github.com/plastic-labs/honcho
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "honcho-cli"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "A terminal for Honcho — memory that reasons."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Honcho CLI — a terminal for Honcho."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -7,6 +7,8 @@
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import time
11
+ import webbrowser
10
12
 
11
13
  import typer
12
14
  from honcho import (
@@ -19,13 +21,14 @@ from honcho import (
19
21
  from rich.console import Console
20
22
  from rich.panel import Panel
21
23
 
22
- from honcho_cli import __version__
24
+ from honcho_cli import __version__, oauth
23
25
  from honcho_cli.branding import BANNER, BRAND, ICON_FAIL, ICON_OK, ICON_RUN
24
- from honcho_cli.common import get_resolved_config
26
+ from honcho_cli.common import get_resolved_config, maybe_refresh_token
25
27
  from honcho_cli.config import (
26
28
  CONFIG_FILE,
27
29
  DEFAULT_BASE_URL,
28
30
  CLIConfig,
31
+ OAuthTokens,
29
32
  )
30
33
  from honcho_cli.output import print_error, print_result, set_json_mode, use_json
31
34
 
@@ -117,21 +120,142 @@ def init(
117
120
  _console.print()
118
121
  _console.print()
119
122
 
123
+ # Non-interactive (JSON/piped) or an explicit --api-key: manual-key path.
124
+ # Device login needs a human at a browser, so it's TTY-only.
125
+ if use_json() or api_key:
126
+ _init_manual_key(key_val, url_val, file_key, file_url)
127
+ else:
128
+ _init_interactive(key_val, url_val, file_url)
129
+
130
+
131
+ def _init_manual_key(key_val: str, url_val: str, file_key: str, file_url: str) -> None:
132
+ """Non-interactive path: confirm/save apiKey + URL, no device login."""
120
133
  final_key = _prompt_api_key(key_val)
121
134
  final_url = _prompt_url(url_val)
122
-
123
- # Persist if anything changed or if the value came from env/flag.
124
135
  if final_key != file_key or final_url != file_url:
125
136
  CLIConfig(base_url=final_url, api_key=final_key).save()
126
137
  if not use_json():
127
138
  _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
128
-
129
139
  _check_connection(final_url, final_key)
130
-
131
140
  if use_json():
132
141
  print_result({"apiKey": _redact(final_key), "baseUrl": final_url})
133
142
 
134
143
 
144
+ def _init_interactive(key_val: str, url_val: str, file_url: str) -> None:
145
+ """Interactive path: URL first (device flow needs the host), then auth method."""
146
+ final_url = _prompt_url(url_val)
147
+ existing = CLIConfig.load()
148
+ has_creds = bool(key_val) or bool(existing.oauth and existing.oauth.access_token)
149
+ # only offer browser login if the host advertises the device grant (managed)
150
+ device_available = oauth.supports_device_login(final_url)
151
+ method = _prompt_auth_method(has_creds, device_available)
152
+
153
+ if method == "keep":
154
+ if final_url != file_url:
155
+ existing.base_url = final_url
156
+ existing.save()
157
+ _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
158
+ # refresh an expired token so "keep" behaves like every live command;
159
+ # a failed refresh surfaces as the connectivity check below, not an abort
160
+ try:
161
+ maybe_refresh_token(existing)
162
+ except typer.Exit:
163
+ pass
164
+ _check_connection(final_url, existing.resolved_api_key())
165
+ return
166
+
167
+ if method == "device":
168
+ tokens = _device_login(final_url)
169
+ CLIConfig(base_url=final_url, oauth=tokens).save()
170
+ _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
171
+ _check_connection(final_url, tokens.access_token)
172
+ return
173
+
174
+ # paste a key
175
+ final_key = _prompt_api_key("")
176
+ CLIConfig(base_url=final_url, api_key=final_key).save()
177
+ _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
178
+ _check_connection(final_url, final_key)
179
+
180
+
181
+ def _prompt_auth_method(has_creds: bool, device_available: bool) -> str:
182
+ """Ask how to authenticate. Returns ``device`` / ``key`` / ``keep``.
183
+
184
+ ``device`` is only offered when the host advertises the device grant; when
185
+ it doesn't, pasting a key is the only login path.
186
+ """
187
+ _console.print(" [dim]How do you want to authenticate?[/dim]")
188
+ options: list[str] = []
189
+ if device_available:
190
+ options.append("device")
191
+ _console.print(f" [dim]({len(options)})[/dim] Log in with your browser (device code)")
192
+ options.append("key")
193
+ _console.print(f" [dim]({len(options)})[/dim] Paste an API key")
194
+ if has_creds:
195
+ options.append("keep")
196
+ _console.print(f" [dim]({len(options)})[/dim] Keep current credentials")
197
+ # default to keeping existing creds so a returning user pressing Enter doesn't
198
+ # get dropped into an unwanted browser login that overwrites them
199
+ default = str(options.index("keep") + 1) if "keep" in options else "1"
200
+ choice = typer.prompt(" Choice", default=default, show_default=True, prompt_suffix=": ").strip()
201
+ try:
202
+ idx = int(choice)
203
+ except ValueError:
204
+ return options[0]
205
+ # explicit 1..len bounds — bare `options[idx - 1]` would let "0"/negatives
206
+ # wrap to the tail of the list via Python's negative indexing
207
+ if 1 <= idx <= len(options):
208
+ return options[idx - 1]
209
+ return options[0]
210
+
211
+
212
+ def _device_login(base_url: str) -> OAuthTokens:
213
+ """Run the device-authorization flow and return the minted tokens.
214
+
215
+ Prints the user code + verification URL, opens the browser best-effort, and
216
+ blocks on the poll loop until the user approves. Exits non-zero on denial,
217
+ expiry, or interrupt.
218
+ """
219
+ endpoints = oauth.resolve_endpoints(base_url)
220
+ try:
221
+ device = oauth.request_device_code(endpoints)
222
+ except oauth.OAuthFlowError as e:
223
+ _console.print(f" {ICON_FAIL} [red]Could not start device login[/red]: {e}")
224
+ raise typer.Exit(1)
225
+
226
+ _console.print()
227
+ _console.print(f" Enter this code to authorize: [bold {BRAND}]{device.user_code}[/bold {BRAND}]")
228
+ _console.print(f" [dim]at[/dim] {device.verification_uri}")
229
+ _console.print()
230
+ try:
231
+ webbrowser.open(device.verification_uri_complete)
232
+ except Exception:
233
+ pass # headless is expected — the URL is printed above
234
+
235
+ try:
236
+ with _console.status("Waiting for approval…", spinner="dots"):
237
+ tokens = oauth.poll_for_token(endpoints, device)
238
+ except oauth.AccessDenied:
239
+ _console.print(f" {ICON_FAIL} [red]Authorization denied[/red]")
240
+ raise typer.Exit(1)
241
+ except (oauth.DeviceCodeExpired, oauth.AuthorizationTimeout):
242
+ _console.print(f" {ICON_FAIL} [red]Code expired[/red] — run `honcho init` to try again")
243
+ raise typer.Exit(1)
244
+ except oauth.OAuthFlowError as e:
245
+ _console.print(f" {ICON_FAIL} [red]Login failed[/red]: {e}")
246
+ raise typer.Exit(1)
247
+ except KeyboardInterrupt:
248
+ _console.print(f" {ICON_FAIL} [red]Cancelled[/red]")
249
+ raise typer.Exit(1)
250
+
251
+ return OAuthTokens.from_response(
252
+ tokens,
253
+ client_id=endpoints.client_id,
254
+ scope_fallback=endpoints.scope,
255
+ host=base_url,
256
+ )
257
+
258
+
135
259
  def _prompt_api_key(value: str) -> str:
136
260
  """Prompt for API key.
137
261
 
@@ -206,6 +330,21 @@ def _check_connection(base_url: str, api_key: str) -> None:
206
330
  # --------------------------------------------------------------------------- #
207
331
  # honcho doctor
208
332
 
333
+ def _auth_mode_detail(config: CLIConfig) -> str:
334
+ """Human summary of which credential the CLI will use."""
335
+ tokens = config.usable_oauth()
336
+ if tokens is not None:
337
+ if tokens.access_valid():
338
+ secs = max(int(tokens.access_expires_at - time.time()), 0)
339
+ return f"OAuth device token (expires in {secs // 60}m)"
340
+ if config.api_key:
341
+ return "API key (OAuth token expired)"
342
+ return "OAuth device token (expired — will refresh)"
343
+ if config.api_key:
344
+ return "API key"
345
+ return "missing — run `honcho init`"
346
+
347
+
209
348
  def doctor(
210
349
  json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
211
350
  ) -> None:
@@ -230,23 +369,30 @@ def doctor(
230
369
  _console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n")
231
370
 
232
371
  config = get_resolved_config()
372
+ # Refresh an expired OAuth token if we can; a failure surfaces as a failed
373
+ # connectivity check below rather than aborting the diagnostic.
374
+ try:
375
+ maybe_refresh_token(config)
376
+ except typer.Exit:
377
+ pass
378
+ key = config.resolved_api_key()
379
+
233
380
  _add("Config file", CONFIG_FILE.exists(),
234
381
  str(CONFIG_FILE) if CONFIG_FILE.exists() else f"{CONFIG_FILE} not found")
235
- _add("API key configured", bool(config.api_key),
236
- "set" if config.api_key else "missing — run `honcho init`")
382
+ _add("Credentials configured", bool(key), _auth_mode_detail(config))
237
383
 
238
- if config.base_url and config.api_key:
239
- _add("API connectivity", *_test_connection(config.base_url, config.api_key))
384
+ if config.base_url and key:
385
+ _add("API connectivity", *_test_connection(config.base_url, key))
240
386
  else:
241
- _add("API connectivity", False, "skipped — no base_url or api_key")
387
+ _add("API connectivity", False, "skipped — no base_url or credentials")
242
388
 
243
389
  # Workspace / peer / queue run only when scoped via -w / -p.
244
390
  ws_ok, client = False, None
245
- if config.workspace_id and config.api_key:
391
+ if config.workspace_id and key:
246
392
  try:
247
393
 
248
394
 
249
- client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id)
395
+ client = Honcho(base_url=config.base_url, api_key=key, workspace_id=config.workspace_id)
250
396
  client.get_configuration()
251
397
  ws_ok = True
252
398
  _add("Workspace reachable", True, config.workspace_id)
@@ -280,7 +426,7 @@ def doctor(
280
426
  _console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n")
281
427
 
282
428
  # Config file + API connectivity are hard requirements.
283
- critical = {"Config file", "API key configured", "API connectivity"}
429
+ critical = {"Config file", "Credentials configured", "API connectivity"}
284
430
  if config.workspace_id:
285
431
  critical.add("Workspace reachable")
286
432
  if any(not c["ok"] for c in checks if c["check"] in critical):
@@ -12,13 +12,15 @@ no-op if the same flag was already set at an outer level.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ from dataclasses import replace
15
16
  from typing import Optional
16
17
 
17
18
  import typer
18
19
 
19
20
  from honcho import Honcho
20
21
 
21
- from honcho_cli.config import CLIConfig, get_client_kwargs
22
+ from honcho_cli import oauth
23
+ from honcho_cli.config import CLIConfig, OAuthTokens, get_client_kwargs
22
24
  from honcho_cli.output import print_error, set_json_mode
23
25
  from honcho_cli.validation import validate_resource_id
24
26
 
@@ -50,6 +52,50 @@ def get_resolved_config():
50
52
  return config
51
53
 
52
54
 
55
+ def maybe_refresh_token(config: CLIConfig) -> None:
56
+ """Refresh an expired OAuth access token in place and persist it.
57
+
58
+ No-op when there is no grant for the current host or the token is still
59
+ valid. A dead grant degrades to the saved apiKey with a warning; exits
60
+ only when nothing is left to authenticate with.
61
+ """
62
+ tokens = config.usable_oauth()
63
+ if tokens is None or tokens.access_valid():
64
+ return
65
+
66
+ if tokens.refresh_token:
67
+ endpoints = oauth.resolve_endpoints(config.base_url)
68
+ if tokens.client_id:
69
+ endpoints = replace(endpoints, client_id=tokens.client_id)
70
+ try:
71
+ refreshed = oauth.refresh_access_token(endpoints, tokens.refresh_token)
72
+ except oauth.OAuthFlowError:
73
+ refreshed = None
74
+ if refreshed is not None:
75
+ # rotation-safe: persist the (possibly new) refresh token before
76
+ # it's reused; keep the old one if the server didn't rotate
77
+ # (refresh_token is optional)
78
+ config.oauth = OAuthTokens.from_response(
79
+ refreshed,
80
+ client_id=tokens.client_id,
81
+ scope_fallback=tokens.scope,
82
+ refresh_fallback=tokens.refresh_token,
83
+ host=tokens.host,
84
+ )
85
+ config.save()
86
+ return
87
+
88
+ if config.api_key:
89
+ typer.echo(
90
+ "OAuth session expired; using the saved API key. "
91
+ "Run `honcho init` to log in again.",
92
+ err=True,
93
+ )
94
+ return
95
+ print_error("SESSION_EXPIRED", "OAuth session expired. Run `honcho init` to log in again.")
96
+ raise typer.Exit(1)
97
+
98
+
53
99
  def get_client(*, require_workspace: bool = True):
54
100
  """Create a Honcho client from resolved config.
55
101
 
@@ -65,6 +111,7 @@ def get_client(*, require_workspace: bool = True):
65
111
  "No workspace scoped. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.",
66
112
  )
67
113
  raise typer.Exit(1)
114
+ maybe_refresh_token(config)
68
115
  return Honcho(**get_client_kwargs(config)), config
69
116
 
70
117
 
@@ -0,0 +1,292 @@
1
+ """Configuration management for Honcho CLI.
2
+
3
+ Config stored at ``~/.honcho/config.json`` with env var overrides. The config
4
+ directory defaults to ``~/.honcho`` and can be relocated with `HONCHO_CONFIG_DIR`
5
+
6
+ The CLI owns these top-level keys in that file:
7
+
8
+ environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev)
9
+ oauth -- OAuth device-grant tokens (accessToken, refreshToken,
10
+ accessExpiresAt, clientId, scope, host), written by
11
+ device login
12
+
13
+ ``apiKey`` (manual admin JWT) is shared with sibling tools: the CLI writes it
14
+ on paste-key login and reads it as a fallback, but never deletes it. A live
15
+ OAuth token takes precedence over ``apiKey`` for the CLI's own calls.
16
+
17
+ All other top-level keys (``hosts``, ``sessions``, ``saveMessages``,
18
+ ``sessionStrategy``, …) are written by sibling Honcho tools and are
19
+ preserved untouched on save.
20
+
21
+ Workspace / peer / session scoping is intentionally *not* persisted here —
22
+ pass ``-w`` / ``-p`` / ``-s`` flags or set ``HONCHO_WORKSPACE_ID`` /
23
+ ``HONCHO_PEER_ID`` / ``HONCHO_SESSION_ID`` per command instead.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import time
31
+ from dataclasses import dataclass, fields
32
+ from pathlib import Path
33
+ from typing import TYPE_CHECKING
34
+
35
+ if TYPE_CHECKING:
36
+ from honcho_cli.oauth import TokenResponse
37
+
38
+ def _config_dir() -> Path:
39
+ """Config directory: ``$HONCHO_CONFIG_DIR`` if set, else ``~/.honcho``."""
40
+ override = os.environ.get("HONCHO_CONFIG_DIR")
41
+ return Path(override).expanduser() if override else Path.home() / ".honcho"
42
+
43
+
44
+ CONFIG_DIR = _config_dir()
45
+ CONFIG_FILE = CONFIG_DIR / "config.json"
46
+
47
+ DEFAULT_BASE_URL = "https://api.honcho.dev"
48
+
49
+
50
+ def _redact_token(token: str) -> str:
51
+ """Show ``***<last4>`` — enough to compare tokens without leaking the body."""
52
+ if not token:
53
+ return ""
54
+ return "***" + token[-4:] if len(token) > 4 else "***"
55
+
56
+
57
+ def _coerce_epoch(value: object) -> float:
58
+ """Parse a persisted epoch-seconds value, treating garbage as expired (0)."""
59
+ if isinstance(value, (int, float)):
60
+ return float(value)
61
+ if isinstance(value, str):
62
+ try:
63
+ return float(value)
64
+ except ValueError:
65
+ return 0.0
66
+ return 0.0
67
+
68
+ # Env var mapping for runtime overrides.
69
+ #
70
+ # Resolution order: flag > env var > config file > default.
71
+ ENV_MAP: dict[str, str] = {
72
+ "api_key": "HONCHO_API_KEY",
73
+ "base_url": "HONCHO_BASE_URL",
74
+ "workspace_id": "HONCHO_WORKSPACE_ID",
75
+ "peer_id": "HONCHO_PEER_ID",
76
+ "session_id": "HONCHO_SESSION_ID",
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class OAuthTokens:
82
+ """Device-grant tokens persisted under the config ``oauth`` key."""
83
+
84
+ access_token: str = ""
85
+ refresh_token: str = ""
86
+ access_expires_at: float = 0.0 # epoch seconds
87
+ client_id: str = ""
88
+ scope: str = ""
89
+ host: str = "" # base_url the grant was minted against
90
+
91
+ def matches_host(self, base_url: str) -> bool:
92
+ """True when the grant belongs to ``base_url``.
93
+
94
+ Tokens are host-scoped — a staging grant must not be sent to prod.
95
+ Legacy blocks with no recorded host are trusted.
96
+ """
97
+ return not self.host or self.host.rstrip("/") == base_url.rstrip("/")
98
+
99
+ def access_valid(self, skew: int = 60) -> bool:
100
+ """True while the access token is present and not within ``skew`` of expiry.
101
+
102
+ Checks the expiry timestamp recorded at mint time, not the token
103
+ itself — the server is the real authority, so a wrong answer here
104
+ costs at most an extra refresh or a 401.
105
+ """
106
+ return bool(self.access_token) and time.time() < self.access_expires_at - skew
107
+
108
+ @classmethod
109
+ def from_response(
110
+ cls,
111
+ resp: TokenResponse,
112
+ *,
113
+ client_id: str,
114
+ scope_fallback: str = "",
115
+ refresh_fallback: str = "",
116
+ host: str = "",
117
+ ) -> OAuthTokens:
118
+ """Build persisted tokens from a token response.
119
+
120
+ ``refresh_fallback`` keeps the prior refresh token when the server
121
+ doesn't rotate one (optional on the refresh grant, RFC 6749 §5.1).
122
+ """
123
+ return cls(
124
+ access_token=resp.access_token,
125
+ refresh_token=resp.refresh_token or refresh_fallback,
126
+ access_expires_at=time.time() + resp.expires_in,
127
+ client_id=client_id,
128
+ scope=resp.scope or scope_fallback,
129
+ host=host,
130
+ )
131
+
132
+
133
+ @dataclass
134
+ class CLIConfig:
135
+ """CLI configuration with layered resolution: flag > env > file > default.
136
+
137
+ ``workspace_id`` / ``peer_id`` / ``session_id`` exist on this dataclass so
138
+ flag/env overrides flow through ``get_client_kwargs()``, but they are
139
+ never read from or written to the config file — they're per-command.
140
+ """
141
+
142
+ base_url: str = DEFAULT_BASE_URL
143
+ api_key: str = ""
144
+ workspace_id: str = ""
145
+ peer_id: str = ""
146
+ session_id: str = ""
147
+ oauth: OAuthTokens | None = None
148
+
149
+ def usable_oauth(self) -> OAuthTokens | None:
150
+ """The OAuth grant, if present and bound to the current host."""
151
+ if (
152
+ self.oauth
153
+ and self.oauth.access_token
154
+ and self.oauth.matches_host(self.base_url)
155
+ ):
156
+ return self.oauth
157
+ return None
158
+
159
+ def resolved_api_key(self) -> str:
160
+ """The key handed to the SDK: a live OAuth token wins, else apiKey.
161
+
162
+ An expired grant loses to a saved apiKey (a dead grant degrades to the
163
+ shared key) but still wins over nothing, since the server is the final
164
+ judge.
165
+ """
166
+ tokens = self.usable_oauth()
167
+ if tokens and tokens.access_valid():
168
+ return tokens.access_token
169
+ if self.api_key:
170
+ return self.api_key
171
+ if tokens:
172
+ return tokens.access_token
173
+ return ""
174
+
175
+ @classmethod
176
+ def load(cls) -> CLIConfig:
177
+ """Load config from file, then overlay env vars."""
178
+ config = cls()
179
+
180
+ if CONFIG_FILE.exists():
181
+ try:
182
+ with open(CONFIG_FILE, encoding="utf-8") as f:
183
+ data = json.load(f)
184
+ except (json.JSONDecodeError, OSError):
185
+ data = {}
186
+
187
+ if isinstance(data, dict):
188
+ url = data.get("environmentUrl")
189
+ if isinstance(url, str) and url:
190
+ config.base_url = url
191
+ key = data.get("apiKey")
192
+ if isinstance(key, str):
193
+ config.api_key = key
194
+ oauth = data.get("oauth")
195
+ if isinstance(oauth, dict) and oauth.get("accessToken"):
196
+ config.oauth = OAuthTokens(
197
+ access_token=str(oauth.get("accessToken", "")),
198
+ refresh_token=str(oauth.get("refreshToken", "")),
199
+ access_expires_at=_coerce_epoch(oauth.get("accessExpiresAt")),
200
+ client_id=str(oauth.get("clientId", "")),
201
+ scope=str(oauth.get("scope", "")),
202
+ host=str(oauth.get("host", "")),
203
+ )
204
+
205
+ for fld_name, env_var in ENV_MAP.items():
206
+ val = os.environ.get(env_var)
207
+ if val:
208
+ setattr(config, fld_name, val)
209
+ elif val == "":
210
+ # SDK reads these env vars directly and crashes on empty
211
+ # strings with a Pydantic ValidationError. Drop them so the
212
+ # SDK falls back to kwargs / defaults.
213
+ os.environ.pop(env_var, None)
214
+
215
+ return config
216
+
217
+ def save(self) -> None:
218
+ """Write ``environmentUrl`` + credentials to config.json.
219
+
220
+ Preserves unrelated top-level keys (``hosts``, ``sessions``,
221
+ ``saveMessages``, ``sessionStrategy``, …) that other tools write.
222
+ ``apiKey`` is written when set but never removed — sibling tools read
223
+ it. The ``oauth`` block is CLI-owned and dropped when empty.
224
+ """
225
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
226
+
227
+ data: dict = {}
228
+ if CONFIG_FILE.exists():
229
+ try:
230
+ with open(CONFIG_FILE, encoding="utf-8") as f:
231
+ loaded = json.load(f)
232
+ if isinstance(loaded, dict):
233
+ data = loaded
234
+ except (json.JSONDecodeError, OSError):
235
+ data = {}
236
+
237
+ data["environmentUrl"] = self.base_url
238
+ if self.api_key:
239
+ data["apiKey"] = self.api_key
240
+
241
+ if self.oauth and self.oauth.access_token:
242
+ data["oauth"] = {
243
+ "accessToken": self.oauth.access_token,
244
+ "refreshToken": self.oauth.refresh_token,
245
+ "accessExpiresAt": self.oauth.access_expires_at,
246
+ "clientId": self.oauth.client_id,
247
+ "scope": self.oauth.scope,
248
+ "host": self.oauth.host,
249
+ }
250
+ else:
251
+ data.pop("oauth", None)
252
+
253
+ CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
254
+ # API key in plaintext — restrict to the owner on multi-user hosts.
255
+ try:
256
+ os.chmod(CONFIG_FILE, 0o600)
257
+ except OSError:
258
+ pass
259
+
260
+ def redacted(self) -> dict[str, str]:
261
+ """Return config dict with api_key redacted.
262
+
263
+ Only includes fields that have a value set — per-command fields
264
+ (workspace_id, peer_id, session_id) are omitted when empty.
265
+ """
266
+ result: dict[str, str] = {}
267
+ for fld in fields(self):
268
+ val = getattr(self, fld.name)
269
+ if not val:
270
+ continue
271
+ if fld.name == "api_key":
272
+ # Show ``***<last4>`` only — enough to compare keys without
273
+ # leaking the header or body of the JWT.
274
+ result[fld.name] = _redact_token(val)
275
+ elif fld.name == "oauth":
276
+ result[fld.name] = _redact_token(val.access_token)
277
+ else:
278
+ result[fld.name] = val
279
+ return result
280
+
281
+
282
+ def get_client_kwargs(config: CLIConfig) -> dict:
283
+ """Build kwargs for Honcho client from config."""
284
+ kwargs: dict = {}
285
+ if config.base_url:
286
+ kwargs["base_url"] = config.base_url
287
+ api_key = config.resolved_api_key()
288
+ if api_key:
289
+ kwargs["api_key"] = api_key
290
+ if config.workspace_id:
291
+ kwargs["workspace_id"] = config.workspace_id
292
+ return kwargs