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.
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/.gitignore +3 -0
- honcho_cli-0.1.2/CHANGELOG.md +29 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/PKG-INFO +12 -11
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/README.md +10 -10
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/pyproject.toml +2 -1
- honcho_cli-0.1.2/scripts/generate_cli_docs.py +261 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/__init__.py +1 -1
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/setup.py +160 -14
- {honcho_cli-0.1.0 → 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.0 → honcho_cli-0.1.2}/uv.lock +3 -1
- honcho_cli-0.1.0/src/honcho_cli/config.py +0 -150
- honcho_cli-0.1.0/src/honcho_cli/skills/CONTEXT.md +0 -50
- honcho_cli-0.1.0/src/honcho_cli/skills/honcho-debug.md +0 -54
- honcho_cli-0.1.0/src/honcho_cli/skills/honcho-inspect.md +0 -53
- honcho_cli-0.1.0/tests/test_config.py +0 -113
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/_help.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/branding.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/__init__.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/conclusion.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/config_cmd.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/message.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/peer.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/session.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/commands/workspace.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/main.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/output.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/src/honcho_cli/validation.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/tests/__init__.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/tests/test_commands.py +0 -0
- {honcho_cli-0.1.0 → honcho_cli-0.1.2}/tests/test_validation.py +0 -0
|
@@ -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.
|
|
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.
|
|
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())
|
|
@@ -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):
|