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.
@@ -0,0 +1,404 @@
1
+ """Session commands: list, inspect, context, summaries, peers, search, representation, metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import List, Optional
7
+
8
+ import typer
9
+
10
+ from honcho import HonchoError
11
+
12
+ from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list
13
+ from honcho_cli.output import print_error, print_result, status, use_json
14
+ from honcho_cli.validation import validate_resource_id
15
+
16
+ from honcho_cli._help import HonchoTyperGroup
17
+ from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
18
+
19
+ app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, create, delete, and manage conversation sessions and their peers.")
20
+ add_common_options(app)
21
+
22
+
23
+ def _get_session_id(session_id: str | None) -> str:
24
+
25
+ config = get_resolved_config()
26
+ sid = session_id or config.session_id
27
+ if not sid:
28
+ print_error("NO_SESSION", "No session ID provided. Pass --session/-s or set HONCHO_SESSION_ID.")
29
+ raise typer.Exit(1)
30
+ return validate_resource_id(sid, "session")
31
+
32
+
33
+ @app.command("list")
34
+ def list_sessions(
35
+ peer_id: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer"),
36
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
37
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
38
+ ) -> None:
39
+ """List sessions in the workspace."""
40
+ handle_cmd_flags(json_output=json_output, workspace=workspace)
41
+ client, config = get_client()
42
+
43
+ try:
44
+ if peer_id:
45
+ peer = client.peer(peer_id)
46
+ raw_sessions = _raw_list(peer.sessions())
47
+ else:
48
+ raw_sessions = _raw_list(client.sessions())
49
+
50
+ items = [
51
+ {
52
+ "id": s.id,
53
+ "is_active": s.is_active,
54
+ "metadata": s.metadata,
55
+ "created_at": str(s.created_at),
56
+ }
57
+ for s in raw_sessions
58
+ ]
59
+ print_result(items, columns=["id", "is_active", "metadata", "created_at"], title="Sessions")
60
+ except Exception as e:
61
+ _handle_error(e, "session", "list")
62
+
63
+
64
+ @app.command("create")
65
+ def create_session(
66
+ session_id: str = typer.Argument(help="Session ID to create or get"),
67
+ peers: Optional[str] = typer.Option(None, "--peers", help="Comma-separated peer IDs to add to the session"),
68
+ metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the session"),
69
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
70
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
71
+ ) -> None:
72
+ """Create or get a session."""
73
+ handle_cmd_flags(json_output=json_output, workspace=workspace)
74
+ sid = validate_resource_id(session_id, "session")
75
+ client, config = get_client()
76
+
77
+ parsed_metadata = None
78
+ if metadata:
79
+ try:
80
+ parsed_metadata = json.loads(metadata)
81
+ except json.JSONDecodeError as e:
82
+ print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
83
+ raise typer.Exit(1)
84
+
85
+ peer_ids = [p.strip() for p in peers.split(",") if p.strip()] if peers else []
86
+ for pid in peer_ids:
87
+ validate_resource_id(pid, "peer")
88
+
89
+ try:
90
+ sess = client.session(sid, metadata=parsed_metadata)
91
+ if peer_ids:
92
+ sess.add_peers(peer_ids)
93
+ result: dict[str, object] = {"session_id": sess.id}
94
+ if parsed_metadata is not None:
95
+ result["metadata"] = parsed_metadata
96
+ if peer_ids:
97
+ result["peers"] = peer_ids
98
+ print_result(result)
99
+ except Exception as e:
100
+ _handle_error(e, "session", sid)
101
+
102
+
103
+ @app.command()
104
+ def inspect(
105
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
106
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
107
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
108
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
109
+ ) -> None:
110
+ """Inspect a session: peers, message count, summaries, config."""
111
+
112
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
113
+ sid = _get_session_id(session_id)
114
+ client, config = get_client()
115
+ sess = client.session(sid)
116
+
117
+ try:
118
+ peers = sess.peers()
119
+ msg_page = sess.messages()
120
+ summaries = sess.summaries()
121
+ sess_config = sess.get_configuration()
122
+
123
+ result = {
124
+ "session_id": sid,
125
+ "peers": [{"id": p.id} for p in peers],
126
+ "message_count": msg_page.total,
127
+ "summaries": {
128
+ "short": summaries.short_summary if hasattr(summaries, "short_summary") else None,
129
+ "long": summaries.long_summary if hasattr(summaries, "long_summary") else None,
130
+ },
131
+ "configuration": _config_to_dict(sess_config) if sess_config else None,
132
+ }
133
+ print_result(result)
134
+ except Exception as e:
135
+ _handle_error(e, "session", sid)
136
+
137
+
138
+ @app.command()
139
+ def context(
140
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
141
+ tokens: Optional[int] = typer.Option(None, help="Token budget"),
142
+ summary: bool = typer.Option(True, help="Include summary"),
143
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
144
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
145
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
146
+ ) -> None:
147
+ """Get session context (what an agent would see)."""
148
+
149
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
150
+ sid = _get_session_id(session_id)
151
+ client, config = get_client()
152
+ sess = client.session(sid)
153
+
154
+ try:
155
+ ctx = sess.context(tokens=tokens, summary=summary)
156
+ result = ctx.__dict__ if hasattr(ctx, "__dict__") else ctx
157
+ print_result(result)
158
+ except Exception as e:
159
+ _handle_error(e, "session", sid)
160
+
161
+
162
+ @app.command()
163
+ def summaries(
164
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
165
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
166
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
167
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
168
+ ) -> None:
169
+ """Get session summaries (short + long)."""
170
+
171
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
172
+ sid = _get_session_id(session_id)
173
+ client, config = get_client()
174
+ sess = client.session(sid)
175
+
176
+ try:
177
+ s = sess.summaries()
178
+ result = {
179
+ "session_id": sid,
180
+ "short_summary": s.short_summary if hasattr(s, "short_summary") else None,
181
+ "long_summary": s.long_summary if hasattr(s, "long_summary") else None,
182
+ }
183
+ print_result(result)
184
+ except Exception as e:
185
+ _handle_error(e, "session", sid)
186
+
187
+
188
+ @app.command()
189
+ def delete(
190
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
191
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
192
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
193
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
194
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
195
+ ) -> None:
196
+ """Delete a session and all its data. Destructive — requires --yes or interactive confirm."""
197
+
198
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
199
+ sid = _get_session_id(session_id)
200
+ client, config = get_client()
201
+ sess = client.session(sid)
202
+
203
+ if not yes:
204
+ # Show a short preview so the user knows what's about to disappear.
205
+ # Only in interactive/TTY mode — scripted (--json) callers already
206
+ # know what they're deleting, and they still need to pass --yes.
207
+ # Narrow the except to HonchoError so auth/network failures surface
208
+ # before the user types 'y' on a destructive op.
209
+ if not use_json():
210
+ try:
211
+ peers = sess.peers()
212
+ msg_page = sess.messages()
213
+ peer_ids = [p.id for p in peers]
214
+ typer.echo(
215
+ f" session: {sid}\n"
216
+ f" peers: {', '.join(peer_ids) if peer_ids else '(none)'}\n"
217
+ f" messages: {msg_page.total}"
218
+ )
219
+ except HonchoError as preview_err:
220
+ status(f"preview unavailable: {preview_err}")
221
+ typer.confirm(f"Delete session '{sid}' and all its messages, conclusions, and queue items?", abort=True)
222
+
223
+ try:
224
+ sess.delete()
225
+ status(f"Session '{sid}' deleted")
226
+ print_result({"deleted": sid})
227
+ except Exception as e:
228
+ _handle_error(e, "session", sid)
229
+
230
+
231
+ @app.command("peers")
232
+ def session_peers(
233
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
234
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
235
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
236
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
237
+ ) -> None:
238
+ """List peers in a session."""
239
+
240
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
241
+ sid = _get_session_id(session_id)
242
+ client, config = get_client()
243
+ sess = client.session(sid)
244
+
245
+ try:
246
+ peers = sess.peers()
247
+ items = [{"id": p.id} for p in peers]
248
+ print_result(items, columns=["id"], title=f"Session peers ({sid})")
249
+ except Exception as e:
250
+ _handle_error(e, "session", sid)
251
+
252
+
253
+ @app.command("add-peers")
254
+ def add_peers(
255
+ session_id: str = typer.Argument(help="Session ID"),
256
+ peer_ids: List[str] = typer.Argument(help="Peer IDs to add to the session"),
257
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
258
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
259
+ ) -> None:
260
+ """Add peers to a session."""
261
+
262
+ handle_cmd_flags(json_output=json_output, workspace=workspace)
263
+ sid = _get_session_id(session_id)
264
+ client, config = get_client()
265
+ sess = client.session(sid)
266
+
267
+ try:
268
+ sess.add_peers(peer_ids)
269
+ print_result({"session_id": sid, "added_peers": peer_ids})
270
+ except Exception as e:
271
+ _handle_error(e, "session", sid)
272
+
273
+
274
+ @app.command("remove-peers")
275
+ def remove_peers(
276
+ session_id: str = typer.Argument(help="Session ID"),
277
+ peer_ids: List[str] = typer.Argument(help="Peer IDs to remove from the session"),
278
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
279
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
280
+ ) -> None:
281
+ """Remove peers from a session."""
282
+
283
+ handle_cmd_flags(json_output=json_output, workspace=workspace)
284
+ sid = _get_session_id(session_id)
285
+ client, config = get_client()
286
+ sess = client.session(sid)
287
+
288
+ try:
289
+ sess.remove_peers(peer_ids)
290
+ print_result({"session_id": sid, "removed_peers": peer_ids})
291
+ except Exception as e:
292
+ _handle_error(e, "session", sid)
293
+
294
+
295
+ @app.command()
296
+ def search(
297
+ query: str = typer.Argument(help="Search query"),
298
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
299
+ limit: int = typer.Option(10, help="Max results"),
300
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
301
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
302
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
303
+ ) -> None:
304
+ """Search messages in a session."""
305
+
306
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
307
+ sid = _get_session_id(session_id)
308
+ client, config = get_client()
309
+ sess = client.session(sid)
310
+
311
+ try:
312
+ results = sess.search(query, limit=limit)
313
+ items = [
314
+ {
315
+ "id": m.id,
316
+ "peer_id": m.peer_id,
317
+ "content": m.content if use_json() else m.content[:200],
318
+ "created_at": str(m.created_at),
319
+ }
320
+ for m in results
321
+ ]
322
+ print_result(items, columns=["id", "peer_id", "content", "created_at"], title=f"Session search: {query}")
323
+ except Exception as e:
324
+ _handle_error(e, "session", sid)
325
+
326
+
327
+ @app.command()
328
+ def representation(
329
+ peer_id: str = typer.Argument(help="Peer ID to get representation for"),
330
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
331
+ target: Optional[str] = typer.Option(None, help="Target peer (what peer_id knows about target)"),
332
+ search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"),
333
+ max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"),
334
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
335
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
336
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
337
+ ) -> None:
338
+ """Get the representation of a peer within a session."""
339
+
340
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
341
+ sid = _get_session_id(session_id)
342
+ client, config = get_client()
343
+ sess = client.session(sid)
344
+
345
+ try:
346
+ result = sess.representation(
347
+ peer_id,
348
+ target=target,
349
+ search_query=search_query,
350
+ max_conclusions=max_conclusions,
351
+ )
352
+ print_result({"session_id": sid, "peer_id": peer_id, "target": target, "representation": result})
353
+ except Exception as e:
354
+ _handle_error(e, "session", sid)
355
+
356
+
357
+ @app.command("get-metadata")
358
+ def get_metadata(
359
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
360
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
361
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
362
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
363
+ ) -> None:
364
+ """Get metadata for a session."""
365
+
366
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
367
+ sid = _get_session_id(session_id)
368
+ client, config = get_client()
369
+ sess = client.session(sid)
370
+
371
+ try:
372
+ result = sess.get_metadata()
373
+ print_result({"session_id": sid, "metadata": result})
374
+ except Exception as e:
375
+ _handle_error(e, "session", sid)
376
+
377
+
378
+ @app.command("set-metadata")
379
+ def set_metadata(
380
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
381
+ metadata: str = typer.Option(..., "--data", "-d", help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"),
382
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
383
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
384
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
385
+ ) -> None:
386
+ """Set metadata for a session."""
387
+
388
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
389
+ sid = _get_session_id(session_id)
390
+ client, config = get_client()
391
+
392
+ try:
393
+ parsed = json.loads(metadata)
394
+ except json.JSONDecodeError as e:
395
+ print_error("INVALID_JSON", f"metadata must be valid JSON: {e}", {})
396
+ raise typer.Exit(1)
397
+
398
+ sess = client.session(sid)
399
+
400
+ try:
401
+ sess.set_metadata(parsed)
402
+ print_result({"session_id": sid, "metadata": parsed})
403
+ except Exception as e:
404
+ _handle_error(e, "session", sid)
@@ -0,0 +1,287 @@
1
+ """Top-level onboarding and health-check commands.
2
+
3
+ `honcho init` — confirm or set apiKey + Honcho URL in ~/.honcho/config.json
4
+ `honcho doctor` — verify connectivity, config validity, queue health
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ import typer
12
+ from honcho import (
13
+ APIError,
14
+ AuthenticationError,
15
+ ConnectionError as HonchoConnectionError,
16
+ Honcho,
17
+ TimeoutError as HonchoTimeoutError,
18
+ )
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+
22
+ from honcho_cli import __version__
23
+ from honcho_cli.branding import BANNER, BRAND, ICON_FAIL, ICON_OK, ICON_RUN
24
+ from honcho_cli.common import get_resolved_config
25
+ from honcho_cli.config import (
26
+ CONFIG_FILE,
27
+ DEFAULT_BASE_URL,
28
+ CLIConfig,
29
+ )
30
+ from honcho_cli.output import print_error, print_result, set_json_mode, use_json
31
+
32
+ _console = Console(stderr=True)
33
+
34
+
35
+ # --------------------------------------------------------------------------- #
36
+ # shared helpers
37
+
38
+ def _redact(api_key: str) -> str:
39
+ """Show ``***<last4>`` — enough to compare keys without leaking the body."""
40
+ if not api_key:
41
+ return ""
42
+ if len(api_key) <= 4:
43
+ return "***"
44
+ return "***" + api_key[-4:]
45
+
46
+
47
+ def _read_file_values() -> tuple[str, str]:
48
+ """Return (apiKey, environmentUrl) persisted on disk (or empty strings)."""
49
+ if not CONFIG_FILE.exists():
50
+ return "", ""
51
+ try:
52
+ with open(CONFIG_FILE, encoding="utf-8") as f:
53
+ data = json.load(f)
54
+ except (json.JSONDecodeError, OSError):
55
+ return "", ""
56
+ if not isinstance(data, dict):
57
+ return "", ""
58
+ key = data.get("apiKey") if isinstance(data.get("apiKey"), str) else ""
59
+ url = data.get("environmentUrl") if isinstance(data.get("environmentUrl"), str) else ""
60
+ return key, url
61
+
62
+
63
+ def _test_connection(base_url: str, api_key: str) -> tuple[bool, str]:
64
+ """Probe the Honcho API by listing workspaces. Returns (ok, detail).
65
+
66
+ Dispatches on the SDK's typed exception hierarchy instead of matching
67
+ substrings of error messages — robust to SDK message changes and locale.
68
+ """
69
+ try:
70
+ list(Honcho(base_url=base_url, api_key=api_key).workspaces())
71
+ return True, "OK"
72
+ except AuthenticationError:
73
+ return False, "Unauthorized — check your API key"
74
+ except HonchoConnectionError:
75
+ return False, "Connection refused — is the server running?"
76
+ except HonchoTimeoutError:
77
+ return False, "Request timed out"
78
+ except APIError as e:
79
+ return False, f"API error ({e.status}): {e}"
80
+ except Exception as e:
81
+ return False, str(e)
82
+
83
+
84
+ def _pick(flag_val: str | None, file_val: str) -> str:
85
+ """Return best available value. Flag/env wins over file."""
86
+ return flag_val or file_val or ""
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+ # honcho init
91
+
92
+ def init(
93
+ api_key: str | None = typer.Option(None, "--api-key", envvar="HONCHO_API_KEY", help="API key (admin JWT)"),
94
+ base_url: str | None = typer.Option(None, "--base-url", envvar="HONCHO_BASE_URL", help="Honcho API URL (e.g. https://api.honcho.dev, http://localhost:8000)"),
95
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
96
+ ) -> None:
97
+ """Set API key and server URL in ~/.honcho/config.json.
98
+
99
+ Press Enter to keep the current value or type a replacement.
100
+ Workspace / peer / session scoping is per-command via -w / -p / -s
101
+ or HONCHO_* env vars — never persisted.
102
+ """
103
+
104
+ if json_output:
105
+ set_json_mode(True)
106
+
107
+ file_key, file_url = _read_file_values()
108
+ key_val = _pick(api_key, file_key)
109
+ url_val = _pick(base_url, file_url).strip()
110
+
111
+ if not use_json():
112
+ _console.print()
113
+ _console.print(Panel(
114
+ f"[bold {BRAND}]{BANNER}[/bold {BRAND}]\n\n Memory that reasons",
115
+ expand=False, subtitle=f"Honcho CLI · v{__version__}",
116
+ ))
117
+ _console.print()
118
+ _console.print()
119
+
120
+ final_key = _prompt_api_key(key_val)
121
+ final_url = _prompt_url(url_val)
122
+
123
+ # Persist if anything changed or if the value came from env/flag.
124
+ if final_key != file_key or final_url != file_url:
125
+ CLIConfig(base_url=final_url, api_key=final_key).save()
126
+ if not use_json():
127
+ _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
128
+
129
+ _check_connection(final_url, final_key)
130
+
131
+ if use_json():
132
+ print_result({"apiKey": _redact(final_key), "baseUrl": final_url})
133
+
134
+
135
+ def _prompt_api_key(value: str) -> str:
136
+ """Prompt for API key.
137
+
138
+ When a key already exists (from env var or config file), the user picks
139
+ between keeping it or entering a replacement. When no key exists, the
140
+ user can paste one or press Enter to skip (local dev with auth disabled
141
+ doesn't need a key).
142
+ """
143
+ if use_json():
144
+ return value
145
+
146
+ if value:
147
+ redacted = _redact(value)
148
+ _console.print(f" [dim]Current API key: {redacted}[/dim]")
149
+ _console.print(" [dim](1)[/dim] Keep current key")
150
+ _console.print(" [dim](2)[/dim] Enter a new key")
151
+ choice = typer.prompt(" Choice", default="1", show_default=True, prompt_suffix=": ").strip()
152
+ if choice == "2":
153
+ raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip()
154
+ return raw
155
+ return value
156
+ else:
157
+ _console.print(" [dim]Not needed for local dev — press Enter to skip[/dim]")
158
+ raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip()
159
+ return raw
160
+
161
+
162
+ def _normalize_url(url: str) -> str:
163
+ """Strip whitespace from the URL."""
164
+ return url.strip()
165
+
166
+
167
+ def _prompt_url(value: str) -> str:
168
+ """Prompt for Honcho URL. Shows current value as the default; Enter keeps it.
169
+
170
+ First run defaults to DEFAULT_BASE_URL. After that, whatever is saved
171
+ in config becomes the default so the user isn't fighting back to their
172
+ custom URL every time.
173
+ """
174
+ if use_json():
175
+ if value:
176
+ return _normalize_url(value)
177
+ print_error("MISSING_VALUE", "Honcho URL is required", {})
178
+ raise typer.Exit(1)
179
+
180
+ default = _normalize_url(value) if value else DEFAULT_BASE_URL
181
+ _console.print(" [dim]Use https://api.honcho.dev for the hosted Honcho instance[/dim]")
182
+ while True:
183
+ raw = typer.prompt(" Honcho URL", default=default, show_default=True, prompt_suffix=": ").strip()
184
+ url = _normalize_url(raw)
185
+ if url.startswith(("http://", "https://")):
186
+ return url
187
+ _console.print(" [red]URL must start with http:// or https://[/red]")
188
+
189
+
190
+ def _check_connection(base_url: str, api_key: str) -> None:
191
+
192
+
193
+ if not use_json():
194
+ _console.print(f"\n {ICON_RUN} [dim]Testing connection to {base_url}...[/dim]", end=" ")
195
+ ok, detail = _test_connection(base_url, api_key)
196
+ if not ok:
197
+ if use_json():
198
+ print_error("CONNECTION_FAILED", detail, {"base_url": base_url})
199
+ else:
200
+ _console.print(f"{ICON_FAIL} [red]Failed[/red]: {detail}")
201
+ raise typer.Exit(1)
202
+ if not use_json():
203
+ _console.print(f"{ICON_OK} [green]Connected[/green]")
204
+
205
+
206
+ # --------------------------------------------------------------------------- #
207
+ # honcho doctor
208
+
209
+ def doctor(
210
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
211
+ ) -> None:
212
+ """Verify config and connectivity. Scope with -w / -p to check workspace, peer, and queue health."""
213
+
214
+
215
+ if json_output:
216
+ set_json_mode(True)
217
+
218
+ checks: list[dict] = []
219
+
220
+ def _add(name: str, ok: bool, detail: str = "") -> None:
221
+ checks.append({"check": name, "ok": ok, "detail": detail})
222
+ if not use_json():
223
+ icon = ICON_OK if ok else ICON_FAIL
224
+ line = f" {icon} {name:<22}"
225
+ if detail:
226
+ line += f" [dim]{detail}[/dim]"
227
+ _console.print(line)
228
+
229
+ if not use_json():
230
+ _console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n")
231
+
232
+ config = get_resolved_config()
233
+ _add("Config file", CONFIG_FILE.exists(),
234
+ 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`")
237
+
238
+ if config.base_url and config.api_key:
239
+ _add("API connectivity", *_test_connection(config.base_url, config.api_key))
240
+ else:
241
+ _add("API connectivity", False, "skipped — no base_url or api_key")
242
+
243
+ # Workspace / peer / queue run only when scoped via -w / -p.
244
+ ws_ok, client = False, None
245
+ if config.workspace_id and config.api_key:
246
+ try:
247
+
248
+
249
+ client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id)
250
+ client.get_configuration()
251
+ ws_ok = True
252
+ _add("Workspace reachable", True, config.workspace_id)
253
+ except Exception as e:
254
+ _add("Workspace reachable", False, f"{config.workspace_id}: {e}")
255
+ if ws_ok:
256
+ try:
257
+ q = client.queue_status()
258
+ _add("Queue health", True, f"{q.completed_work_units}/{q.total_work_units} completed, {q.pending_work_units} pending")
259
+ except Exception:
260
+ _add("Queue health", True, "endpoint not available (non-critical)")
261
+
262
+ if config.peer_id:
263
+ if ws_ok and client is not None:
264
+ try:
265
+ client.peer(config.peer_id).get_card()
266
+ _add("Peer exists", True, config.peer_id)
267
+ except Exception as e:
268
+ _add("Peer exists", False, f"{config.peer_id}: {e}")
269
+ else:
270
+ _add("Peer exists", False, "skipped — workspace not reachable")
271
+
272
+ passed = sum(1 for c in checks if c["ok"])
273
+ total = len(checks)
274
+
275
+ if use_json():
276
+ print_result({"checks": checks, "passed": passed, "total": total})
277
+ else:
278
+ color = BRAND if passed == total else ("yellow" if passed > total // 2 else "red")
279
+ hint = "" if config.workspace_id else " [dim](pass -w / -p to include workspace, peer, queue checks)[/dim]"
280
+ _console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n")
281
+
282
+ # Config file + API connectivity are hard requirements.
283
+ critical = {"Config file", "API key configured", "API connectivity"}
284
+ if config.workspace_id:
285
+ critical.add("Workspace reachable")
286
+ if any(not c["ok"] for c in checks if c["check"] in critical):
287
+ raise typer.Exit(1)