honcho-cli 0.1.0__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 (36) hide show
  1. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/.gitignore +3 -0
  2. honcho_cli-0.1.2/CHANGELOG.md +29 -0
  3. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/PKG-INFO +12 -11
  4. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/README.md +10 -10
  5. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/pyproject.toml +2 -1
  6. honcho_cli-0.1.2/scripts/generate_cli_docs.py +261 -0
  7. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/__init__.py +1 -1
  8. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/setup.py +160 -14
  9. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/common.py +48 -1
  10. honcho_cli-0.1.2/src/honcho_cli/config.py +292 -0
  11. honcho_cli-0.1.2/src/honcho_cli/oauth.py +260 -0
  12. honcho_cli-0.1.2/tests/conftest.py +21 -0
  13. honcho_cli-0.1.2/tests/test_common.py +126 -0
  14. honcho_cli-0.1.2/tests/test_config.py +266 -0
  15. honcho_cli-0.1.2/tests/test_oauth.py +232 -0
  16. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/uv.lock +3 -1
  17. honcho_cli-0.1.0/src/honcho_cli/config.py +0 -150
  18. honcho_cli-0.1.0/src/honcho_cli/skills/CONTEXT.md +0 -50
  19. honcho_cli-0.1.0/src/honcho_cli/skills/honcho-debug.md +0 -54
  20. honcho_cli-0.1.0/src/honcho_cli/skills/honcho-inspect.md +0 -53
  21. honcho_cli-0.1.0/tests/test_config.py +0 -113
  22. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/_help.py +0 -0
  23. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/branding.py +0 -0
  24. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/__init__.py +0 -0
  25. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/conclusion.py +0 -0
  26. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/config_cmd.py +0 -0
  27. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/message.py +0 -0
  28. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/peer.py +0 -0
  29. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/session.py +0 -0
  30. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/workspace.py +0 -0
  31. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/main.py +0 -0
  32. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/output.py +0 -0
  33. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/validation.py +0 -0
  34. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/tests/__init__.py +0 -0
  35. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/tests/test_commands.py +0 -0
  36. {honcho_cli-0.1.0 → honcho_cli-0.1.2}/tests/test_validation.py +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
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
+ and this project adheres to [Semantic Versioning](http://semver.org/).
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
+
14
+ ## [0.1.1] - 2026-06-15
15
+
16
+ ### Fixed
17
+
18
+ - Declare `click` as an explicit dependency. The CLI imported `click` directly but relied on it being pulled in transitively, so installs without it on the path could fail at runtime (#787)
19
+
20
+ ## [0.1.0] - 2026-04-20
21
+
22
+ ### Added
23
+
24
+ - Initial release of `honcho-cli` — a terminal for inspecting and managing a Honcho deployment (#424)
25
+ - `workspace`, `peer`, `session`, `message`, `conclusion`, and `config` command groups for managing resources against any Honcho server
26
+ - `init` onboarding flow that prompts for and persists connection settings, with flag/env-var pre-seeding for non-interactive use
27
+ - Per-command flags, environment variables, and a config file for pointing the CLI at different servers (local, self-hosted, or hosted)
28
+ - Rich terminal output and an agent-usage mode for scripting against the CLI
29
+ - Documentation and an agent skill for the CLI (#589)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: honcho-cli
3
- Version: 0.1.0
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
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Topic :: Software Development :: Libraries
15
15
  Requires-Python: >=3.11
16
+ Requires-Dist: click>=8.0.0
16
17
  Requires-Dist: honcho-ai>=2.0.0
17
18
  Requires-Dist: httpx>=0.27.0
18
19
  Requires-Dist: rich>=13.0.0
@@ -43,16 +44,6 @@ As a standalone tool (recommended):
43
44
  uv tool install honcho-cli
44
45
  ```
45
46
 
46
- As an extra on the Honcho SDK (if you want both the SDK and the CLI in one project):
47
-
48
- ```bash
49
- uv add honcho-ai[cli]
50
- # or
51
- pip install honcho-ai[cli]
52
- ```
53
-
54
- Either way, you'll get the `honcho` command on your PATH.
55
-
56
47
  ## Quick Start
57
48
 
58
49
  ```bash
@@ -166,6 +157,16 @@ Non-interactive onboarding:
166
157
  HONCHO_API_KEY=hch-v3-xxx honcho init --base-url https://api.honcho.dev
167
158
  ```
168
159
 
160
+ ## Agent skill
161
+
162
+ `honcho-cli` ships with a skill that teaches agents the right commands and conventions for inspecting and debugging a Honcho deployment. Install it anywhere skills are accepted (Claude Code, other skill-aware agents):
163
+
164
+ ```bash
165
+ npx skills add plastic-labs/honcho
166
+ ```
167
+
168
+ The picker lists every skill for Honcho — select `honcho-cli` .
169
+
169
170
  ## Environment Variables
170
171
 
171
172
  All `HONCHO_*` env vars work at runtime — no config file required.
@@ -19,16 +19,6 @@ As a standalone tool (recommended):
19
19
  uv tool install honcho-cli
20
20
  ```
21
21
 
22
- As an extra on the Honcho SDK (if you want both the SDK and the CLI in one project):
23
-
24
- ```bash
25
- uv add honcho-ai[cli]
26
- # or
27
- pip install honcho-ai[cli]
28
- ```
29
-
30
- Either way, you'll get the `honcho` command on your PATH.
31
-
32
22
  ## Quick Start
33
23
 
34
24
  ```bash
@@ -142,6 +132,16 @@ Non-interactive onboarding:
142
132
  HONCHO_API_KEY=hch-v3-xxx honcho init --base-url https://api.honcho.dev
143
133
  ```
144
134
 
135
+ ## Agent skill
136
+
137
+ `honcho-cli` ships with a skill that teaches agents the right commands and conventions for inspecting and debugging a Honcho deployment. Install it anywhere skills are accepted (Claude Code, other skill-aware agents):
138
+
139
+ ```bash
140
+ npx skills add plastic-labs/honcho
141
+ ```
142
+
143
+ The picker lists every skill for Honcho — select `honcho-cli` .
144
+
145
145
  ## Environment Variables
146
146
 
147
147
  All `HONCHO_*` env vars work at runtime — no config file required.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "honcho-cli"
3
- version = "0.1.0"
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"
@@ -17,6 +17,7 @@ classifiers = [
17
17
  "Topic :: Software Development :: Libraries",
18
18
  ]
19
19
  dependencies = [
20
+ "click>=8.0.0",
20
21
  "typer>=0.15.0",
21
22
  "honcho-ai>=2.0.0",
22
23
  "rich>=13.0.0",
@@ -0,0 +1,261 @@
1
+ """Generate ``docs/snippets/cli-commands.mdx`` from the Typer app.
2
+
3
+ Walks the ``honcho`` Typer app and emits a Mintlify snippet using native
4
+ Mintlify components: ``<AccordionGroup>`` / ``<Accordion>`` for subcommand
5
+ grouping and ``<ParamField>`` for each argument and option. The output is a
6
+ single snippet included by ``docs/v3/documentation/reference/cli.mdx``.
7
+
8
+ Usage::
9
+
10
+ uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py
11
+
12
+ # Or as a drift check (non-zero exit if the committed snippet is stale):
13
+ uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py --check
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from argparse import ArgumentParser
20
+ from pathlib import Path
21
+
22
+ import click
23
+ import typer.main
24
+
25
+ from honcho_cli.main import app
26
+
27
+ REPO_ROOT = Path(__file__).resolve().parents[2]
28
+ OUTPUT = REPO_ROOT / "docs" / "snippets" / "cli-commands.mdx"
29
+
30
+ HEADER = """{/*
31
+ GENERATED by honcho-cli/scripts/generate_cli_docs.py — do not edit.
32
+ Re-generate with: uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py
33
+ Source of truth: honcho-cli/src/honcho_cli/commands/
34
+ */}
35
+
36
+ """
37
+
38
+ # Documented once in cli.mdx's Configuration table. Skip at the per-command
39
+ # level so each Accordion only shows options specific to that subcommand.
40
+ GLOBAL_OPTIONS: set[tuple[str, str]] = {
41
+ ("--workspace", "Override workspace ID"),
42
+ ("--peer", "Override peer ID"),
43
+ ("--session", "Override session ID"),
44
+ ("--json", "Force JSON output"),
45
+ }
46
+
47
+
48
+ def _escape_mdx(text: str) -> str:
49
+ """Escape MDX-sensitive characters in prose so Mintlify's parser doesn't
50
+ mistake ``{...}`` for a JSX expression or ``<x>`` for a JSX tag."""
51
+ return (
52
+ text.replace("\\", "\\\\")
53
+ .replace("{", "\\{")
54
+ .replace("}", "\\}")
55
+ .replace("<", "\\<")
56
+ )
57
+
58
+
59
+ def _attr(value: str) -> str:
60
+ """Escape a string for use inside a JSX double-quoted attribute value."""
61
+ return value.replace("\\", "\\\\").replace('"', "'")
62
+
63
+
64
+ def _long_opt(param: click.Option) -> str | None:
65
+ return next((o for o in param.opts if o.startswith("--")), None)
66
+
67
+
68
+ def _short_opt(param: click.Option) -> str | None:
69
+ return next(
70
+ (o for o in param.opts if o.startswith("-") and not o.startswith("--")),
71
+ None,
72
+ )
73
+
74
+
75
+ def _is_global(param: click.Parameter) -> bool:
76
+ if not isinstance(param, click.Option) or not param.help:
77
+ return False
78
+ return (_long_opt(param), param.help) in GLOBAL_OPTIONS
79
+
80
+
81
+ def _param_type(param: click.Parameter) -> str:
82
+ if isinstance(param, click.Option) and param.is_flag:
83
+ return "boolean"
84
+ if isinstance(param.type, click.Choice):
85
+ return "string"
86
+ name = getattr(param.type, "name", "")
87
+ if name in ("integer", "int"):
88
+ return "number"
89
+ if name in ("float", "decimal"):
90
+ return "number"
91
+ if name == "boolean":
92
+ return "boolean"
93
+ return "string"
94
+
95
+
96
+ def _param_path(param: click.Parameter) -> str:
97
+ if isinstance(param, click.Argument):
98
+ return param.name or ""
99
+ return _long_opt(param) or (param.opts[0] if param.opts else "")
100
+
101
+
102
+ def _param_required(param: click.Parameter) -> bool:
103
+ if isinstance(param, click.Argument):
104
+ return param.required
105
+ if isinstance(param, click.Option):
106
+ return bool(param.required)
107
+ return False
108
+
109
+
110
+ def _default_attr(param: click.Parameter) -> str | None:
111
+ default = param.default
112
+ if default is None or default is False or callable(default):
113
+ return None
114
+ if isinstance(default, (list, tuple)) and not default:
115
+ return None
116
+ if default is True:
117
+ return "true"
118
+ return _attr(str(default))
119
+
120
+
121
+ def _ensure_period(text: str) -> str:
122
+ return text if text.endswith((".", "?", "!", ":")) else text + "."
123
+
124
+
125
+ def _param_body(param: click.Parameter) -> str:
126
+ parts: list[str] = []
127
+ if isinstance(param, click.Option):
128
+ if param.help:
129
+ parts.append(_ensure_period(_escape_mdx(param.help.strip())))
130
+ short = _short_opt(param)
131
+ if short:
132
+ parts.append(f"Short alias: `{short}`.")
133
+ if param.secondary_opts:
134
+ neg = " / ".join(f"`{o}`" for o in param.secondary_opts)
135
+ parts.append(f"Negate with {neg}.")
136
+ if isinstance(param.type, click.Choice):
137
+ choices = ", ".join(f"`{c}`" for c in param.type.choices)
138
+ parts.append(f"One of: {choices}.")
139
+ return " ".join(parts)
140
+
141
+
142
+ def _render_param(param: click.Parameter) -> list[str]:
143
+ props = [
144
+ f'path="{_attr(_param_path(param))}"',
145
+ f'type="{_param_type(param)}"',
146
+ ]
147
+ if _param_required(param):
148
+ props.append("required")
149
+ default_attr = _default_attr(param)
150
+ if default_attr is not None:
151
+ props.append(f'default="{default_attr}"')
152
+ body = _param_body(param).strip()
153
+ open_tag = f"<ParamField {' '.join(props)}>"
154
+ if body:
155
+ return [open_tag, f" {body}", "</ParamField>"]
156
+ return [open_tag.replace(">", " />")]
157
+
158
+
159
+ def _params_of(
160
+ cmd: click.Command, *, strip_globals: bool
161
+ ) -> list[click.Parameter]:
162
+ args = [p for p in cmd.params if isinstance(p, click.Argument)]
163
+ opts = [
164
+ p
165
+ for p in cmd.params
166
+ if isinstance(p, click.Option)
167
+ and not p.hidden
168
+ and not (strip_globals and _is_global(p))
169
+ ]
170
+ return args + opts
171
+
172
+
173
+ def _invocation_line(cmd: click.Command, path: list[str]) -> str:
174
+ args = [p for p in cmd.params if isinstance(p, click.Argument)]
175
+ parts = [" ".join(path)]
176
+ for a in args:
177
+ placeholder = f"<{a.name}>"
178
+ if not a.required:
179
+ placeholder = f"[{placeholder}]"
180
+ parts.append(placeholder)
181
+ return " ".join(parts)
182
+
183
+
184
+ def _render_accordion(cmd: click.Command, path: list[str]) -> list[str]:
185
+ lines = [f'<Accordion title="{_attr(path[-1])}">']
186
+ if cmd.help:
187
+ lines.append(_escape_mdx(cmd.help.strip()))
188
+ lines.append("")
189
+ lines.append("```bash")
190
+ lines.append(_invocation_line(cmd, path))
191
+ lines.append("```")
192
+ lines.append("")
193
+ for p in _params_of(cmd, strip_globals=True):
194
+ lines.extend(_render_param(p))
195
+ lines.append("</Accordion>")
196
+ return lines
197
+
198
+
199
+ def _render_top(cmd: click.Command, path: list[str]) -> list[str]:
200
+ lines = [f"## {' '.join(path)}", ""]
201
+ if cmd.help:
202
+ lines.append(_escape_mdx(cmd.help.strip()))
203
+ lines.append("")
204
+
205
+ if isinstance(cmd, click.Group) and cmd.commands:
206
+ lines.append("<AccordionGroup>")
207
+ for sub_name in sorted(cmd.commands):
208
+ lines.extend(
209
+ _render_accordion(cmd.commands[sub_name], path + [sub_name])
210
+ )
211
+ lines.append("</AccordionGroup>")
212
+ lines.append("")
213
+ return lines
214
+
215
+ lines.append("```bash")
216
+ lines.append(_invocation_line(cmd, path))
217
+ lines.append("```")
218
+ lines.append("")
219
+ for p in _params_of(cmd, strip_globals=True):
220
+ lines.extend(_render_param(p))
221
+ lines.append("")
222
+ return lines
223
+
224
+
225
+ def build() -> str:
226
+ root: click.Command = typer.main.get_command(app)
227
+ if not isinstance(root, click.Group):
228
+ raise SystemExit("Expected root command to be a Group")
229
+
230
+ body: list[str] = []
231
+ for name in sorted(root.commands):
232
+ body.extend(_render_top(root.commands[name], ["honcho", name]))
233
+ return HEADER + "\n".join(body) + "\n"
234
+
235
+
236
+ def main() -> int:
237
+ parser = ArgumentParser()
238
+ parser.add_argument(
239
+ "--check",
240
+ action="store_true",
241
+ help="Exit non-zero if the committed snippet differs from generated output.",
242
+ )
243
+ ns = parser.parse_args()
244
+ generated = build()
245
+ if ns.check:
246
+ current = OUTPUT.read_text() if OUTPUT.exists() else ""
247
+ if current != generated:
248
+ print(
249
+ f"::error::{OUTPUT} is stale. Re-run without --check to regenerate.",
250
+ file=sys.stderr,
251
+ )
252
+ return 1
253
+ return 0
254
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
255
+ OUTPUT.write_text(generated)
256
+ print(f"Wrote {OUTPUT}")
257
+ return 0
258
+
259
+
260
+ if __name__ == "__main__":
261
+ raise SystemExit(main())
@@ -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):