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,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()