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.
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/.gitignore +3 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/CHANGELOG.md +6 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/PKG-INFO +1 -1
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/pyproject.toml +1 -1
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/__init__.py +1 -1
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/setup.py +160 -14
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/common.py +48 -1
- honcho_cli-0.1.2/src/honcho_cli/config.py +292 -0
- honcho_cli-0.1.2/src/honcho_cli/oauth.py +260 -0
- honcho_cli-0.1.2/tests/conftest.py +21 -0
- honcho_cli-0.1.2/tests/test_common.py +126 -0
- honcho_cli-0.1.2/tests/test_config.py +266 -0
- honcho_cli-0.1.2/tests/test_oauth.py +232 -0
- honcho_cli-0.1.1/src/honcho_cli/config.py +0 -150
- honcho_cli-0.1.1/tests/test_config.py +0 -113
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/README.md +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/scripts/generate_cli_docs.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/_help.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/branding.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/__init__.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/conclusion.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/config_cmd.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/message.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/peer.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/session.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/commands/workspace.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/main.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/output.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/src/honcho_cli/validation.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/tests/__init__.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/tests/test_commands.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/tests/test_validation.py +0 -0
- {honcho_cli-0.1.1 → honcho_cli-0.1.2}/uv.lock +0 -0
|
@@ -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
|
|
@@ -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("
|
|
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
|
|
239
|
-
_add("API connectivity", *_test_connection(config.base_url,
|
|
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
|
|
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
|
|
391
|
+
if config.workspace_id and key:
|
|
246
392
|
try:
|
|
247
393
|
|
|
248
394
|
|
|
249
|
-
client = Honcho(base_url=config.base_url, api_key=
|
|
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", "
|
|
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
|
|
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
|