honcho-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- honcho_cli/_help.py +130 -0
- honcho_cli/branding.py +17 -0
- honcho_cli/commands/__init__.py +0 -0
- honcho_cli/commands/conclusion.py +219 -0
- honcho_cli/commands/config_cmd.py +31 -0
- honcho_cli/commands/message.py +161 -0
- honcho_cli/commands/peer.py +308 -0
- honcho_cli/commands/session.py +404 -0
- honcho_cli/commands/setup.py +287 -0
- honcho_cli/commands/workspace.py +322 -0
- honcho_cli/common.py +112 -0
- honcho_cli/config.py +150 -0
- honcho_cli/main.py +96 -0
- honcho_cli/output.py +104 -0
- honcho_cli/skills/CONTEXT.md +50 -0
- honcho_cli/skills/honcho-debug.md +54 -0
- honcho_cli/skills/honcho-inspect.md +53 -0
- honcho_cli/validation.py +44 -0
- honcho_cli-0.1.0.dist-info/METADATA +235 -0
- honcho_cli-0.1.0.dist-info/RECORD +23 -0
- honcho_cli-0.1.0.dist-info/WHEEL +4 -0
- honcho_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Workspace commands: list, inspect, create, delete, search, queue-status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from honcho import (
|
|
11
|
+
APIError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
Honcho,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PermissionDeniedError,
|
|
16
|
+
ServerError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from honcho_cli.output import print_error, print_result, status, use_json
|
|
20
|
+
from honcho_cli.validation import validate_resource_id
|
|
21
|
+
|
|
22
|
+
from honcho_cli._help import HonchoTyperGroup
|
|
23
|
+
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, inspect, delete, and search workspaces.")
|
|
26
|
+
add_common_options(app)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_workspace_id(workspace_id: str | None) -> str:
|
|
30
|
+
|
|
31
|
+
config = get_resolved_config()
|
|
32
|
+
wid = workspace_id or config.workspace_id
|
|
33
|
+
if not wid:
|
|
34
|
+
print_error("NO_WORKSPACE", "No workspace ID provided. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
return validate_resource_id(wid, "workspace")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _raw_list(page) -> list:
|
|
40
|
+
"""Collect all raw API response items across all pages of a SyncPage."""
|
|
41
|
+
items = list(page._raw_items)
|
|
42
|
+
while page.has_next_page():
|
|
43
|
+
page = page.get_next_page()
|
|
44
|
+
if page is None:
|
|
45
|
+
break
|
|
46
|
+
items.extend(page._raw_items)
|
|
47
|
+
return items
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("list")
|
|
51
|
+
def list_workspaces(
|
|
52
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""List all accessible workspaces."""
|
|
55
|
+
|
|
56
|
+
handle_cmd_flags(json_output=json_output)
|
|
57
|
+
client, config = get_client(require_workspace=False)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
workspaces = list(client.workspaces())
|
|
61
|
+
items = [{"id": w} for w in workspaces]
|
|
62
|
+
print_result(items, columns=["id"], title="Workspaces")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
_handle_error(e, "workspace", "list")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("create")
|
|
68
|
+
def create_workspace(
|
|
69
|
+
workspace_id: str = typer.Argument(help="Workspace ID to create or get"),
|
|
70
|
+
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the workspace"),
|
|
71
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Create or get a workspace."""
|
|
74
|
+
handle_cmd_flags(json_output=json_output)
|
|
75
|
+
wid = validate_resource_id(workspace_id, "workspace")
|
|
76
|
+
client, config = get_client(require_workspace=False)
|
|
77
|
+
ws_client = _with_workspace(client, wid)
|
|
78
|
+
|
|
79
|
+
parsed_metadata = None
|
|
80
|
+
if metadata:
|
|
81
|
+
try:
|
|
82
|
+
parsed_metadata = json.loads(metadata)
|
|
83
|
+
except json.JSONDecodeError as e:
|
|
84
|
+
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
# Trigger get-or-create via the workspace ensure mechanism
|
|
89
|
+
ws_client.get_configuration()
|
|
90
|
+
result: dict[str, object] = {"workspace_id": wid}
|
|
91
|
+
if parsed_metadata is not None:
|
|
92
|
+
ws_client.set_metadata(parsed_metadata)
|
|
93
|
+
result["metadata"] = parsed_metadata
|
|
94
|
+
print_result(result)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
_handle_error(e, "workspace", wid)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def inspect(
|
|
101
|
+
workspace_id: Optional[str] = typer.Argument(None, help="Workspace ID (uses default if omitted)"),
|
|
102
|
+
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
103
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Inspect a workspace: peers, sessions, config."""
|
|
106
|
+
|
|
107
|
+
handle_cmd_flags(json_output=json_output, workspace=workspace)
|
|
108
|
+
|
|
109
|
+
wid = _get_workspace_id(workspace_id)
|
|
110
|
+
client, config = get_client(require_workspace=False)
|
|
111
|
+
|
|
112
|
+
# Override workspace if positional arg given
|
|
113
|
+
if workspace_id:
|
|
114
|
+
client = _with_workspace(client, workspace_id)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
ws_config = client.get_configuration()
|
|
118
|
+
ws_metadata = client.get_metadata()
|
|
119
|
+
peer_page = client.peers()
|
|
120
|
+
session_page = client.sessions()
|
|
121
|
+
|
|
122
|
+
raw_peers = peer_page._raw_items
|
|
123
|
+
raw_sessions = session_page._raw_items
|
|
124
|
+
|
|
125
|
+
result = {
|
|
126
|
+
"workspace_id": wid,
|
|
127
|
+
"metadata": ws_metadata,
|
|
128
|
+
"configuration": _config_to_dict(ws_config) if ws_config else None,
|
|
129
|
+
"peer_count": peer_page.total,
|
|
130
|
+
"session_count": session_page.total,
|
|
131
|
+
"peers": [
|
|
132
|
+
{"id": p.id, "metadata": p.metadata, "created_at": str(p.created_at)}
|
|
133
|
+
for p in raw_peers[:20]
|
|
134
|
+
],
|
|
135
|
+
"sessions": [
|
|
136
|
+
{"id": s.id, "is_active": s.is_active, "metadata": s.metadata, "created_at": str(s.created_at)}
|
|
137
|
+
for s in raw_sessions[:20]
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
print_result(result)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
_handle_error(e, "workspace", wid)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def delete(
|
|
147
|
+
workspace_id: str = typer.Argument(help="Workspace ID to delete"),
|
|
148
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt (for scripted/agent use)"),
|
|
149
|
+
cascade: bool = typer.Option(False, "--cascade", help="Delete all sessions before deleting the workspace"),
|
|
150
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"),
|
|
151
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Delete a workspace. Use --dry-run first to see what will be deleted.
|
|
154
|
+
|
|
155
|
+
Requires --yes to skip confirmation, or will prompt interactively.
|
|
156
|
+
If sessions exist, requires --cascade to delete them first.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
handle_cmd_flags(json_output=json_output)
|
|
160
|
+
|
|
161
|
+
validate_resource_id(workspace_id, "workspace")
|
|
162
|
+
# workspace_id is a required positional and we rebuild the client with it
|
|
163
|
+
# immediately, so the default-workspace guard isn't needed here.
|
|
164
|
+
client, config = get_client(require_workspace=False)
|
|
165
|
+
ws_client = _with_workspace(client, workspace_id)
|
|
166
|
+
|
|
167
|
+
# Verify workspace exists before prompting for confirmation
|
|
168
|
+
try:
|
|
169
|
+
ws_client.get_metadata()
|
|
170
|
+
except Exception as e:
|
|
171
|
+
_handle_error(e, "workspace", workspace_id)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Always fetch sessions for dry-run or cascade
|
|
175
|
+
raw_sessions = _raw_list(ws_client.sessions()) if (dry_run or cascade) else []
|
|
176
|
+
|
|
177
|
+
if dry_run:
|
|
178
|
+
print_result({
|
|
179
|
+
"dry_run": True,
|
|
180
|
+
"workspace_id": workspace_id,
|
|
181
|
+
"sessions_to_delete": len(raw_sessions),
|
|
182
|
+
"session_ids": [s.id for s in raw_sessions],
|
|
183
|
+
"warning": "This action cannot be undone.",
|
|
184
|
+
})
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
if not yes:
|
|
188
|
+
if cascade and raw_sessions:
|
|
189
|
+
typer.confirm(
|
|
190
|
+
f"Delete workspace '{workspace_id}' and {len(raw_sessions)} session(s)? This cannot be undone.",
|
|
191
|
+
abort=True,
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
typer.confirm(f"Delete workspace '{workspace_id}'? This cannot be undone.", abort=True)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
deleted_sessions = []
|
|
198
|
+
if cascade and raw_sessions:
|
|
199
|
+
for s in raw_sessions:
|
|
200
|
+
ws_client.session(s.id).delete()
|
|
201
|
+
deleted_sessions.append(s.id)
|
|
202
|
+
status(f"Deleted session '{s.id}'")
|
|
203
|
+
|
|
204
|
+
ws_client.delete_workspace(workspace_id)
|
|
205
|
+
status(f"Workspace '{workspace_id}' deletion accepted (processing in background)")
|
|
206
|
+
result = {"deleted_workspace": workspace_id, "status": "accepted"}
|
|
207
|
+
if cascade:
|
|
208
|
+
result["deleted_sessions"] = deleted_sessions
|
|
209
|
+
print_result(result)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
_handle_error(e, "workspace", workspace_id)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command()
|
|
215
|
+
def search(
|
|
216
|
+
query: str = typer.Argument(help="Search query"),
|
|
217
|
+
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
218
|
+
limit: int = typer.Option(10, help="Max results"),
|
|
219
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Search messages across workspace."""
|
|
222
|
+
|
|
223
|
+
handle_cmd_flags(json_output=json_output, workspace=workspace)
|
|
224
|
+
|
|
225
|
+
wid = _get_workspace_id(None)
|
|
226
|
+
client, config = get_client()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
results = client.search(query, limit=limit)
|
|
230
|
+
items = [
|
|
231
|
+
{
|
|
232
|
+
"id": m.id,
|
|
233
|
+
"content": m.content if use_json() else m.content[:200],
|
|
234
|
+
"peer_id": m.peer_id,
|
|
235
|
+
"session_id": m.session_id,
|
|
236
|
+
"created_at": str(m.created_at),
|
|
237
|
+
}
|
|
238
|
+
for m in results
|
|
239
|
+
]
|
|
240
|
+
print_result(items, columns=["id", "peer_id", "session_id", "content"], title=f"Search: {query}")
|
|
241
|
+
except Exception as e:
|
|
242
|
+
_handle_error(e, "workspace", wid)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command("queue-status")
|
|
246
|
+
def queue_status(
|
|
247
|
+
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
248
|
+
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
|
|
249
|
+
observer: Optional[str] = typer.Option(None, help="Filter by observer peer"),
|
|
250
|
+
sender: Optional[str] = typer.Option(None, help="Filter by sender peer"),
|
|
251
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Get queue processing status."""
|
|
254
|
+
|
|
255
|
+
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
|
|
256
|
+
|
|
257
|
+
_get_workspace_id(None)
|
|
258
|
+
client, config = get_client()
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
result = client.queue_status(observer=observer, sender=sender, session=config.session_id or None)
|
|
262
|
+
print_result(result.__dict__ if hasattr(result, "__dict__") else result)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
_handle_error(e, "queue", "status")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _with_workspace(client, workspace_id: str):
|
|
268
|
+
"""Return a new client pointed at a different workspace."""
|
|
269
|
+
return Honcho(
|
|
270
|
+
base_url=str(client.base_url),
|
|
271
|
+
api_key=client._http.api_key if hasattr(client._http, "api_key") else None,
|
|
272
|
+
workspace_id=workspace_id,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _config_to_dict(config) -> dict:
|
|
277
|
+
"""Convert a config object to a dict, handling nested objects."""
|
|
278
|
+
if hasattr(config, "__dict__"):
|
|
279
|
+
result = {}
|
|
280
|
+
for k, v in config.__dict__.items():
|
|
281
|
+
if k.startswith("_"):
|
|
282
|
+
continue
|
|
283
|
+
result[k] = _config_to_dict(v) if hasattr(v, "__dict__") and not isinstance(v, str) else v
|
|
284
|
+
return result
|
|
285
|
+
return config
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _handle_error(e: Exception, resource: str, resource_id: str) -> None:
|
|
289
|
+
"""Handle SDK exceptions with structured error output.
|
|
290
|
+
|
|
291
|
+
Dispatches on the SDK's typed exception hierarchy
|
|
292
|
+
(``honcho.http.exceptions``) and falls back to its ``status`` field for
|
|
293
|
+
any APIError subclass we don't enumerate. Substring matching on the
|
|
294
|
+
message is used only as a last-ditch fallback for non-SDK exceptions.
|
|
295
|
+
"""
|
|
296
|
+
if isinstance(e, NotFoundError):
|
|
297
|
+
print_error(
|
|
298
|
+
f"{resource.upper()}_NOT_FOUND",
|
|
299
|
+
f"{resource.title()} '{resource_id}' not found",
|
|
300
|
+
{resource: resource_id},
|
|
301
|
+
)
|
|
302
|
+
raise typer.Exit(1)
|
|
303
|
+
if isinstance(e, AuthenticationError):
|
|
304
|
+
print_error("AUTH_ERROR", f"Authentication failed: {e}", {})
|
|
305
|
+
raise typer.Exit(3)
|
|
306
|
+
if isinstance(e, PermissionDeniedError):
|
|
307
|
+
print_error("PERMISSION_ERROR", f"Permission denied: {e}", {})
|
|
308
|
+
raise typer.Exit(3)
|
|
309
|
+
if isinstance(e, ServerError):
|
|
310
|
+
print_error("SERVER_ERROR", f"Server error: {e}", {resource: resource_id})
|
|
311
|
+
raise typer.Exit(2)
|
|
312
|
+
if isinstance(e, APIError):
|
|
313
|
+
# Catch-all for typed API errors we haven't special-cased
|
|
314
|
+
# (BadRequest, Conflict, UnprocessableEntity, RateLimit, ...).
|
|
315
|
+
print_error(
|
|
316
|
+
"API_ERROR",
|
|
317
|
+
f"API error ({e.status}): {e}",
|
|
318
|
+
{resource: resource_id, "status": e.status},
|
|
319
|
+
)
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
print_error("UNKNOWN_ERROR", str(e), {resource: resource_id})
|
|
322
|
+
raise typer.Exit(1)
|
honcho_cli/common.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Shared runtime state, client factory, and command-level flag helpers.
|
|
2
|
+
|
|
3
|
+
Flags --json, -w, -p, -s are documented at **command-level** (the canonical
|
|
4
|
+
form demonstrated in the welcome panel, README, and skill files):
|
|
5
|
+
|
|
6
|
+
honcho workspace list -w granola --json
|
|
7
|
+
|
|
8
|
+
They also parse at group-level and top-level for flexibility. All three
|
|
9
|
+
positions resolve identically and are idempotent — command-level is a
|
|
10
|
+
no-op if the same flag was already set at an outer level.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from honcho import Honcho
|
|
20
|
+
|
|
21
|
+
from honcho_cli.config import CLIConfig, get_client_kwargs
|
|
22
|
+
from honcho_cli.output import print_error, set_json_mode
|
|
23
|
+
from honcho_cli.validation import validate_resource_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Global overrides from flags (commands read these)
|
|
27
|
+
_global_overrides: dict[str, str | None] = {
|
|
28
|
+
"workspace": None,
|
|
29
|
+
"peer": None,
|
|
30
|
+
"session": None,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_resolved_config():
|
|
35
|
+
"""Get config with global flag overrides applied.
|
|
36
|
+
|
|
37
|
+
Overrides flow through ``validate_resource_id`` so that a malformed
|
|
38
|
+
``-w``/``-p``/``-s`` value fails fast with a structured error rather than
|
|
39
|
+
reaching the API and surfacing as an opaque ``UNKNOWN_ERROR``.
|
|
40
|
+
"""
|
|
41
|
+
config = CLIConfig.load()
|
|
42
|
+
|
|
43
|
+
if _global_overrides["workspace"]:
|
|
44
|
+
config.workspace_id = validate_resource_id(_global_overrides["workspace"], "workspace")
|
|
45
|
+
if _global_overrides["peer"]:
|
|
46
|
+
config.peer_id = validate_resource_id(_global_overrides["peer"], "peer")
|
|
47
|
+
if _global_overrides["session"]:
|
|
48
|
+
config.session_id = validate_resource_id(_global_overrides["session"], "session")
|
|
49
|
+
|
|
50
|
+
return config
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_client(*, require_workspace: bool = True):
|
|
54
|
+
"""Create a Honcho client from resolved config.
|
|
55
|
+
|
|
56
|
+
By default, refuses to build a client when no workspace is scoped — the
|
|
57
|
+
SDK's get-or-create semantics would otherwise silently operate on an empty
|
|
58
|
+
workspace. Commands that legitimately run without a workspace (e.g.
|
|
59
|
+
``workspace list``) pass ``require_workspace=False``.
|
|
60
|
+
"""
|
|
61
|
+
config = get_resolved_config()
|
|
62
|
+
if require_workspace and not config.workspace_id:
|
|
63
|
+
print_error(
|
|
64
|
+
"NO_WORKSPACE",
|
|
65
|
+
"No workspace scoped. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.",
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
return Honcho(**get_client_kwargs(config)), config
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def handle_cmd_flags(
|
|
72
|
+
json_output: bool = False,
|
|
73
|
+
workspace: str | None = None,
|
|
74
|
+
peer: str | None = None,
|
|
75
|
+
session: str | None = None,
|
|
76
|
+
**_kwargs,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Apply command-level flags. Idempotent if already set by group callback."""
|
|
79
|
+
if json_output:
|
|
80
|
+
set_json_mode(True)
|
|
81
|
+
|
|
82
|
+
if workspace:
|
|
83
|
+
_global_overrides["workspace"] = workspace
|
|
84
|
+
if peer:
|
|
85
|
+
_global_overrides["peer"] = peer
|
|
86
|
+
if session:
|
|
87
|
+
_global_overrides["session"] = session
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def add_common_options(app: typer.Typer) -> None:
|
|
91
|
+
"""Add a callback to a sub-app that accepts --json, -w, -p, -s."""
|
|
92
|
+
|
|
93
|
+
@app.callback(invoke_without_command=True)
|
|
94
|
+
def _callback(
|
|
95
|
+
ctx: typer.Context,
|
|
96
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
97
|
+
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
98
|
+
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
|
|
99
|
+
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
|
|
100
|
+
) -> None:
|
|
101
|
+
if json_output:
|
|
102
|
+
set_json_mode(True)
|
|
103
|
+
|
|
104
|
+
if workspace:
|
|
105
|
+
_global_overrides["workspace"] = workspace
|
|
106
|
+
if peer:
|
|
107
|
+
_global_overrides["peer"] = peer
|
|
108
|
+
if session:
|
|
109
|
+
_global_overrides["session"] = session
|
|
110
|
+
|
|
111
|
+
if ctx.invoked_subcommand is None:
|
|
112
|
+
typer.echo(ctx.get_help())
|
honcho_cli/config.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Configuration management for Honcho CLI.
|
|
2
|
+
|
|
3
|
+
Config stored at ``~/.honcho/config.json`` with env var overrides.
|
|
4
|
+
|
|
5
|
+
The CLI owns exactly two top-level keys in that file:
|
|
6
|
+
|
|
7
|
+
apiKey -- Honcho admin JWT
|
|
8
|
+
environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev)
|
|
9
|
+
|
|
10
|
+
All other top-level keys (``hosts``, ``sessions``, ``saveMessages``,
|
|
11
|
+
``sessionStrategy``, …) are written by sibling Honcho tools and are
|
|
12
|
+
preserved untouched on save.
|
|
13
|
+
|
|
14
|
+
Workspace / peer / session scoping is intentionally *not* persisted here —
|
|
15
|
+
pass ``-w`` / ``-p`` / ``-s`` flags or set ``HONCHO_WORKSPACE_ID`` /
|
|
16
|
+
``HONCHO_PEER_ID`` / ``HONCHO_SESSION_ID`` per command instead.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass, fields
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
CONFIG_DIR = Path.home() / ".honcho"
|
|
27
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.honcho.dev"
|
|
30
|
+
|
|
31
|
+
# Env var mapping for runtime overrides.
|
|
32
|
+
#
|
|
33
|
+
# Resolution order: flag > env var > config file > default.
|
|
34
|
+
ENV_MAP: dict[str, str] = {
|
|
35
|
+
"api_key": "HONCHO_API_KEY",
|
|
36
|
+
"base_url": "HONCHO_BASE_URL",
|
|
37
|
+
"workspace_id": "HONCHO_WORKSPACE_ID",
|
|
38
|
+
"peer_id": "HONCHO_PEER_ID",
|
|
39
|
+
"session_id": "HONCHO_SESSION_ID",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class CLIConfig:
|
|
45
|
+
"""CLI configuration with layered resolution: flag > env > file > default.
|
|
46
|
+
|
|
47
|
+
``workspace_id`` / ``peer_id`` / ``session_id`` exist on this dataclass so
|
|
48
|
+
flag/env overrides flow through ``get_client_kwargs()``, but they are
|
|
49
|
+
never read from or written to the config file — they're per-command.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
base_url: str = DEFAULT_BASE_URL
|
|
53
|
+
api_key: str = ""
|
|
54
|
+
workspace_id: str = ""
|
|
55
|
+
peer_id: str = ""
|
|
56
|
+
session_id: str = ""
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def load(cls) -> CLIConfig:
|
|
60
|
+
"""Load config from file, then overlay env vars."""
|
|
61
|
+
config = cls()
|
|
62
|
+
|
|
63
|
+
if CONFIG_FILE.exists():
|
|
64
|
+
try:
|
|
65
|
+
with open(CONFIG_FILE, encoding="utf-8") as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
except (json.JSONDecodeError, OSError):
|
|
68
|
+
data = {}
|
|
69
|
+
|
|
70
|
+
if isinstance(data, dict):
|
|
71
|
+
url = data.get("environmentUrl")
|
|
72
|
+
if isinstance(url, str) and url:
|
|
73
|
+
config.base_url = url
|
|
74
|
+
key = data.get("apiKey")
|
|
75
|
+
if isinstance(key, str):
|
|
76
|
+
config.api_key = key
|
|
77
|
+
|
|
78
|
+
for fld_name, env_var in ENV_MAP.items():
|
|
79
|
+
val = os.environ.get(env_var)
|
|
80
|
+
if val:
|
|
81
|
+
setattr(config, fld_name, val)
|
|
82
|
+
elif val == "":
|
|
83
|
+
# SDK reads these env vars directly and crashes on empty
|
|
84
|
+
# strings with a Pydantic ValidationError. Drop them so the
|
|
85
|
+
# SDK falls back to kwargs / defaults.
|
|
86
|
+
os.environ.pop(env_var, None)
|
|
87
|
+
|
|
88
|
+
return config
|
|
89
|
+
|
|
90
|
+
def save(self) -> None:
|
|
91
|
+
"""Write ``apiKey`` + ``environmentUrl`` to config.json.
|
|
92
|
+
|
|
93
|
+
Preserves unrelated top-level keys (``hosts``, ``sessions``,
|
|
94
|
+
``saveMessages``, ``sessionStrategy``, …) that other tools write.
|
|
95
|
+
"""
|
|
96
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
data: dict = {}
|
|
99
|
+
if CONFIG_FILE.exists():
|
|
100
|
+
try:
|
|
101
|
+
with open(CONFIG_FILE, encoding="utf-8") as f:
|
|
102
|
+
loaded = json.load(f)
|
|
103
|
+
if isinstance(loaded, dict):
|
|
104
|
+
data = loaded
|
|
105
|
+
except (json.JSONDecodeError, OSError):
|
|
106
|
+
data = {}
|
|
107
|
+
|
|
108
|
+
data["environmentUrl"] = self.base_url
|
|
109
|
+
if self.api_key:
|
|
110
|
+
data["apiKey"] = self.api_key
|
|
111
|
+
else:
|
|
112
|
+
data.pop("apiKey", None)
|
|
113
|
+
|
|
114
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
115
|
+
# API key in plaintext — restrict to the owner on multi-user hosts.
|
|
116
|
+
try:
|
|
117
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def redacted(self) -> dict[str, str]:
|
|
122
|
+
"""Return config dict with api_key redacted.
|
|
123
|
+
|
|
124
|
+
Only includes fields that have a value set — per-command fields
|
|
125
|
+
(workspace_id, peer_id, session_id) are omitted when empty.
|
|
126
|
+
"""
|
|
127
|
+
d: dict[str, str] = {}
|
|
128
|
+
for fld in fields(self):
|
|
129
|
+
val = getattr(self, fld.name)
|
|
130
|
+
if not val:
|
|
131
|
+
continue
|
|
132
|
+
if fld.name == "api_key":
|
|
133
|
+
# Show ``***<last4>`` only — enough to compare keys without
|
|
134
|
+
# leaking the header or body of the JWT.
|
|
135
|
+
d[fld.name] = "***" + val[-4:] if len(val) > 4 else "***"
|
|
136
|
+
else:
|
|
137
|
+
d[fld.name] = val
|
|
138
|
+
return d
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_client_kwargs(config: CLIConfig) -> dict:
|
|
142
|
+
"""Build kwargs for Honcho client from config."""
|
|
143
|
+
kwargs: dict = {}
|
|
144
|
+
if config.base_url:
|
|
145
|
+
kwargs["base_url"] = config.base_url
|
|
146
|
+
if config.api_key:
|
|
147
|
+
kwargs["api_key"] = config.api_key
|
|
148
|
+
if config.workspace_id:
|
|
149
|
+
kwargs["workspace_id"] = config.workspace_id
|
|
150
|
+
return kwargs
|
honcho_cli/main.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Honcho CLI — a terminal for Honcho.
|
|
2
|
+
|
|
3
|
+
Entry point and top-level command group.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from honcho_cli import __version__
|
|
15
|
+
from honcho_cli._help import HonchoTyperGroup, print_welcome
|
|
16
|
+
from honcho_cli.branding import BANNER
|
|
17
|
+
from honcho_cli.output import set_json_mode
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="honcho",
|
|
22
|
+
cls=HonchoTyperGroup,
|
|
23
|
+
help="A terminal for Honcho — memory that reasons.",
|
|
24
|
+
invoke_without_command=True,
|
|
25
|
+
pretty_exceptions_enable=False,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _json_requested_early() -> bool:
|
|
31
|
+
"""Best-effort JSON detection before Typer parses flags.
|
|
32
|
+
|
|
33
|
+
version_callback is eager and fires before set_json_mode() runs, so we
|
|
34
|
+
can't call use_json() here. Mirror its logic against argv/env/TTY.
|
|
35
|
+
"""
|
|
36
|
+
return (
|
|
37
|
+
"--json" in sys.argv
|
|
38
|
+
or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true")
|
|
39
|
+
or not sys.stdout.isatty()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def version_callback(value: bool) -> None:
|
|
44
|
+
if value:
|
|
45
|
+
if not _json_requested_early():
|
|
46
|
+
print(BANNER)
|
|
47
|
+
print(f" honcho-cli {__version__}")
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def main(
|
|
53
|
+
ctx: typer.Context,
|
|
54
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
55
|
+
version: bool = typer.Option(False, "--version", "-V", callback=version_callback, is_eager=True, help="Show version"),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Honcho CLI — admin & debugging tool for Honcho workspaces."""
|
|
58
|
+
set_json_mode(json_output)
|
|
59
|
+
|
|
60
|
+
if ctx.invoked_subcommand is None:
|
|
61
|
+
print_welcome(Console())
|
|
62
|
+
raise typer.Exit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Register top-level commands
|
|
66
|
+
from honcho_cli.commands.setup import doctor, init
|
|
67
|
+
|
|
68
|
+
app.command()(init)
|
|
69
|
+
app.command()(doctor)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("help", hidden=True)
|
|
73
|
+
def help_cmd(ctx: typer.Context) -> None:
|
|
74
|
+
"""Show help message."""
|
|
75
|
+
Console().print(ctx.parent.get_help() if ctx.parent else "")
|
|
76
|
+
raise typer.Exit()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Register command groups
|
|
80
|
+
from honcho_cli.commands.config_cmd import app as config_app
|
|
81
|
+
from honcho_cli.commands.conclusion import app as conclusion_app
|
|
82
|
+
from honcho_cli.commands.message import app as message_app
|
|
83
|
+
from honcho_cli.commands.peer import app as peer_app
|
|
84
|
+
from honcho_cli.commands.session import app as session_app
|
|
85
|
+
from honcho_cli.commands.workspace import app as workspace_app
|
|
86
|
+
|
|
87
|
+
app.add_typer(peer_app, name="peer")
|
|
88
|
+
app.add_typer(session_app, name="session")
|
|
89
|
+
app.add_typer(message_app, name="message")
|
|
90
|
+
app.add_typer(conclusion_app, name="conclusion")
|
|
91
|
+
app.add_typer(workspace_app, name="workspace")
|
|
92
|
+
app.add_typer(config_app, name="config")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
app()
|