hx-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.
Files changed (64) hide show
  1. hx/__init__.py +5 -0
  2. hx/agents/__init__.py +1 -0
  3. hx/agents/definitions.py +106 -0
  4. hx/agents/subagent.py +190 -0
  5. hx/cli.py +667 -0
  6. hx/config.py +277 -0
  7. hx/core/__init__.py +1 -0
  8. hx/core/compaction.py +245 -0
  9. hx/core/context.py +271 -0
  10. hx/core/events.py +183 -0
  11. hx/core/lateinject.py +121 -0
  12. hx/core/loop.py +537 -0
  13. hx/core/messages.py +164 -0
  14. hx/core/session.py +208 -0
  15. hx/core/usage.py +129 -0
  16. hx/frontmatter.py +80 -0
  17. hx/mcp/__init__.py +1 -0
  18. hx/mcp/client.py +319 -0
  19. hx/mcp/manager.py +265 -0
  20. hx/paths.py +90 -0
  21. hx/permissions/__init__.py +7 -0
  22. hx/permissions/engine.py +406 -0
  23. hx/permissions/parser.py +306 -0
  24. hx/permissions/sandbox.py +227 -0
  25. hx/providers/__init__.py +1 -0
  26. hx/providers/base.py +77 -0
  27. hx/providers/fake.py +87 -0
  28. hx/providers/models.py +238 -0
  29. hx/providers/openrouter.py +468 -0
  30. hx/skills/__init__.py +1 -0
  31. hx/skills/loader.py +102 -0
  32. hx/skills/runtime.py +84 -0
  33. hx/tools/__init__.py +1 -0
  34. hx/tools/base.py +97 -0
  35. hx/tools/bash.py +544 -0
  36. hx/tools/edit.py +167 -0
  37. hx/tools/glob.py +75 -0
  38. hx/tools/grep.py +165 -0
  39. hx/tools/output.py +133 -0
  40. hx/tools/read.py +142 -0
  41. hx/tools/registry.py +149 -0
  42. hx/tools/task.py +76 -0
  43. hx/tools/todo.py +149 -0
  44. hx/tools/write.py +87 -0
  45. hx/tui/__init__.py +1 -0
  46. hx/tui/app.py +487 -0
  47. hx/tui/commands.py +399 -0
  48. hx/tui/hx.tcss +197 -0
  49. hx/tui/renderers.py +570 -0
  50. hx/tui/theme.py +322 -0
  51. hx/tui/widgets/__init__.py +1 -0
  52. hx/tui/widgets/configure.py +95 -0
  53. hx/tui/widgets/diff.py +25 -0
  54. hx/tui/widgets/input.py +145 -0
  55. hx/tui/widgets/palette.py +130 -0
  56. hx/tui/widgets/permission.py +97 -0
  57. hx/tui/widgets/statusbar.py +212 -0
  58. hx/tui/widgets/todos.py +116 -0
  59. hx/tui/widgets/transcript.py +316 -0
  60. hx/tui/widgets/working.py +78 -0
  61. hx_cli-0.1.0.dist-info/METADATA +430 -0
  62. hx_cli-0.1.0.dist-info/RECORD +64 -0
  63. hx_cli-0.1.0.dist-info/WHEEL +4 -0
  64. hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
hx/cli.py ADDED
@@ -0,0 +1,667 @@
1
+ """Command-line entry point.
2
+
3
+ ``hx`` launch the TUI in the current directory
4
+ ``hx -p "..."`` print mode: run one prompt headless, stream to stdout
5
+ ``hx resume [id]`` resume a session
6
+ ``hx mcp ...`` manage MCP servers
7
+ ``hx upgrade`` self-update via uv
8
+
9
+ Print mode exists so the harness is scriptable and E2E-testable without driving
10
+ a terminal UI.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import getpass
18
+ import subprocess
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from hx import __version__
25
+ from hx.paths import auth_file
26
+
27
+ USAGE = """\
28
+ hx - a terminal coding agent
29
+
30
+ Usage:
31
+ hx Start the interactive TUI
32
+ hx -p, --print PROMPT Run one prompt headlessly and print the result
33
+ hx resume [SESSION_ID] Resume a previous session
34
+ hx mcp list|add|remove Manage MCP servers
35
+ hx auth [set|clear] Show or change the OpenRouter API key
36
+ hx upgrade Update hx to the latest version
37
+ hx --version Show version
38
+ hx --help Show this message
39
+
40
+ Options:
41
+ --model MODEL Override the model for this run
42
+ --mode MODE plan | default | acceptEdits | bypass
43
+ --cwd PATH Run against a different project directory
44
+ --no-sandbox Disable OS sandboxing (permission rules still apply)
45
+ """
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class ParsedArgs:
50
+ command: str = "tui"
51
+ prompt: str = ""
52
+ session_id: str | None = None
53
+ rest: tuple[str, ...] = ()
54
+ overrides: dict[str, Any] | None = None
55
+ cwd: Path | None = None
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ """Parse arguments and dispatch. Returns a process exit code."""
60
+ args = list(sys.argv[1:] if argv is None else argv)
61
+
62
+ if "--version" in args or "-V" in args:
63
+ print(f"hx {__version__}")
64
+ return 0
65
+ if "--help" in args or "-h" in args:
66
+ print(USAGE)
67
+ return 0
68
+
69
+ return _dispatch(args)
70
+
71
+
72
+ def _dispatch(args: list[str]) -> int:
73
+ try:
74
+ parsed = parse_args(args)
75
+ except UsageError as exc:
76
+ print(f"error: {exc}\n\n{USAGE}", file=sys.stderr)
77
+ return 2
78
+
79
+ if parsed.command == "upgrade":
80
+ return run_upgrade_command()
81
+ if parsed.command == "mcp":
82
+ return run_mcp_command(list(parsed.rest))
83
+ if parsed.command == "auth":
84
+ return run_auth_command(list(parsed.rest))
85
+ if parsed.command == "print":
86
+ return run_print_command(parsed)
87
+ return run_tui_command(parsed)
88
+
89
+
90
+ def parse_args(args: list[str]) -> ParsedArgs:
91
+ parsed = ParsedArgs()
92
+ overrides: dict[str, Any] = {}
93
+ positional: list[str] = []
94
+
95
+ index = 0
96
+ while index < len(args):
97
+ arg = args[index]
98
+ if arg in {"-p", "--print"}:
99
+ parsed.command = "print"
100
+ index += 1
101
+ if index >= len(args):
102
+ raise UsageError("-p requires a prompt")
103
+ parsed.prompt = args[index]
104
+ elif arg == "--model":
105
+ index += 1
106
+ if index >= len(args):
107
+ raise UsageError("--model requires a value")
108
+ overrides.setdefault("models", {})["model"] = args[index]
109
+ elif arg == "--mode":
110
+ index += 1
111
+ if index >= len(args):
112
+ raise UsageError("--mode requires a value")
113
+ overrides.setdefault("permissions", {})["mode"] = args[index]
114
+ elif arg == "--cwd":
115
+ index += 1
116
+ if index >= len(args):
117
+ raise UsageError("--cwd requires a path")
118
+ parsed.cwd = Path(args[index]).expanduser()
119
+ elif arg == "--no-sandbox":
120
+ overrides.setdefault("permissions", {})["sandbox"] = False
121
+ elif arg.startswith("-"):
122
+ raise UsageError(f"unknown option {arg}")
123
+ else:
124
+ positional.append(arg)
125
+ index += 1
126
+
127
+ if positional:
128
+ head, *tail = positional
129
+ if head in {"resume", "mcp", "upgrade", "auth"}:
130
+ parsed.command = head
131
+ parsed.rest = tuple(tail)
132
+ if head == "resume" and tail:
133
+ parsed.session_id = tail[0]
134
+ elif parsed.command != "print":
135
+ raise UsageError(f"unknown command {head!r}")
136
+
137
+ parsed.overrides = overrides or None
138
+ return parsed
139
+
140
+
141
+ @dataclass(slots=True)
142
+ class Runtime:
143
+ """Everything one HX run needs, wired together."""
144
+
145
+ settings: Any
146
+ session: Any
147
+ bus: Any
148
+ loop: Any
149
+ models: Any
150
+ api_key: str
151
+ provider: Any
152
+ shell: Any = None
153
+ jobs: Any = None
154
+ sandbox: Any = None
155
+ skills: Any = None
156
+ agents: Any = None
157
+ mcp: Any = None
158
+ tools: Any = None
159
+
160
+ @property
161
+ def sandbox_active(self) -> bool:
162
+ return bool(self.sandbox is not None and self.sandbox.active)
163
+
164
+ @property
165
+ def sandbox_backend(self) -> str:
166
+ return str(self.sandbox.backend.value) if self.sandbox is not None else "none"
167
+
168
+ async def connect_mcp(self) -> list[Any]:
169
+ """Connect MCP servers and register their tools.
170
+
171
+ Done after the loop is built and never at import time: a slow or broken
172
+ server should delay tool availability, not startup itself.
173
+ """
174
+ if self.mcp is None:
175
+ return []
176
+ statuses: list[Any] = await self.mcp.connect_all()
177
+ await self.mcp.register_tools(self.tools)
178
+ return statuses
179
+
180
+ async def refresh_models_if_stale(self) -> None:
181
+ """Re-fetch the model catalogue once a day.
182
+
183
+ Without this the cache written on first run never updates, so /model
184
+ would show last month's prices and context windows forever. A failure
185
+ here is not worth interrupting the session for - the cached catalogue
186
+ still works.
187
+ """
188
+ if not self.models.is_stale:
189
+ return
190
+ with contextlib.suppress(Exception):
191
+ await self.models.refresh(self.api_key)
192
+
193
+ async def aclose(self) -> None:
194
+ if self.mcp is not None:
195
+ await self.mcp.close_all()
196
+ if self.jobs is not None:
197
+ await self.jobs.close_all()
198
+ if self.shell is not None:
199
+ await self.shell.close()
200
+ if self.sandbox is not None:
201
+ self.sandbox.cleanup()
202
+ await self.provider.aclose()
203
+
204
+
205
+ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
206
+ """Boot the stack: settings, provider, session, sandbox, tools, loop.
207
+
208
+ Compaction, skills, subagents and MCP land in later milestones; the loop
209
+ already accepts them, so nothing here changes when they arrive.
210
+ """
211
+ from hx.agents.definitions import discover as discover_agents
212
+ from hx.agents.subagent import SubagentRunner
213
+ from hx.config import load_settings
214
+ from hx.core.compaction import Compactor
215
+ from hx.core.context import ContextBuilder, build_project_context, load_system_prompt
216
+ from hx.core.events import EventBus
217
+ from hx.core.lateinject import Injection, InjectionRegistry
218
+ from hx.core.loop import AgentLoop
219
+ from hx.core.session import load_session, new_session
220
+ from hx.mcp.manager import MCPManager
221
+ from hx.mcp.manager import load_configs as load_mcp_configs
222
+ from hx.paths import ensure_user_dirs, session_outputs_dir
223
+ from hx.permissions.engine import PermissionEngine, load_rules
224
+ from hx.permissions.sandbox import Sandbox, default_policy
225
+ from hx.providers.models import ModelRegistry
226
+ from hx.providers.openrouter import OpenRouterProvider, load_api_key
227
+ from hx.skills.loader import build_index
228
+ from hx.skills.loader import discover as discover_skills
229
+ from hx.skills.runtime import ActiveSkills, SkillTool
230
+ from hx.tools.bash import BackgroundJobs, PersistentShell
231
+ from hx.tools.read import FileTracker
232
+ from hx.tools.registry import build_default_registry
233
+ from hx.tools.task import TaskTool
234
+ from hx.tools.todo import TodoList, todo_injector
235
+
236
+ ensure_user_dirs()
237
+ settings = load_settings(parsed.cwd, parsed.overrides)
238
+ bus_holder = EventBus()
239
+
240
+ api_key = load_api_key()
241
+ provider = OpenRouterProvider(api_key)
242
+
243
+ models = ModelRegistry()
244
+ models.load_cache()
245
+ model_info = models.get_or_default(settings.models.model)
246
+
247
+ session = load_session(resume) if resume else new_session(settings.cwd, settings.models.model)
248
+
249
+ sandbox = (
250
+ Sandbox(default_policy(settings.cwd, settings.permissions.allow_network))
251
+ if settings.permissions.sandbox
252
+ else None
253
+ )
254
+ shell = PersistentShell(settings.cwd, shell=settings.bash.shell, sandbox=sandbox)
255
+ jobs = BackgroundJobs(session_outputs_dir(session.meta.session_id) / "jobs")
256
+ tracker = FileTracker()
257
+
258
+ todos = TodoList()
259
+ injections = InjectionRegistry()
260
+ injections.register("todos", todo_injector(todos))
261
+ injections.register("stale_files", _stale_files_injector(tracker, Injection))
262
+
263
+ permissions = PermissionEngine(
264
+ mode=settings.permissions.mode,
265
+ rules=[
266
+ *load_rules(settings.cwd),
267
+ *_rules_from_settings(settings.permissions),
268
+ ],
269
+ cwd=settings.cwd,
270
+ )
271
+
272
+ context_builder = ContextBuilder(
273
+ load_system_prompt(settings.cwd),
274
+ settings.cwd,
275
+ keep_recent_turns=settings.context.keep_recent_turns,
276
+ )
277
+
278
+ tools = build_default_registry(shell, jobs, tracker, todos, bus_holder)
279
+
280
+ skills = {skill.name: skill for skill in discover_skills(settings.cwd)}
281
+ if skills:
282
+ tools.register(SkillTool(skills, ActiveSkills()))
283
+
284
+ agents = {agent.name: agent for agent in discover_agents(settings.cwd)}
285
+ subagents = SubagentRunner(
286
+ definitions=agents,
287
+ provider=provider,
288
+ tools=tools,
289
+ permissions=permissions,
290
+ bus=bus_holder,
291
+ settings=settings,
292
+ models=models,
293
+ parent_session_id=session.meta.session_id,
294
+ parent_usage=session.usage,
295
+ )
296
+ tools.register(TaskTool(subagents))
297
+
298
+ loop = AgentLoop(
299
+ provider=provider,
300
+ session=session,
301
+ tools=tools,
302
+ permissions=permissions,
303
+ context=context_builder,
304
+ compactor=Compactor(
305
+ provider=provider,
306
+ model=settings.models.model,
307
+ keep_recent_turns=settings.context.keep_recent_turns,
308
+ context=context_builder,
309
+ ),
310
+ injections=injections,
311
+ bus=bus_holder,
312
+ settings=settings,
313
+ model_info=model_info,
314
+ skills_index=build_index(list(skills.values())) or None,
315
+ project_context=build_project_context(settings.cwd),
316
+ )
317
+
318
+ return Runtime(
319
+ settings=settings,
320
+ session=session,
321
+ bus=bus_holder,
322
+ loop=loop,
323
+ models=models,
324
+ api_key=api_key,
325
+ provider=provider,
326
+ shell=shell,
327
+ jobs=jobs,
328
+ sandbox=sandbox,
329
+ skills=skills,
330
+ agents=agents,
331
+ mcp=MCPManager(load_mcp_configs(settings.cwd)),
332
+ tools=tools,
333
+ )
334
+
335
+
336
+ def _stale_files_injector(tracker: Any, injection_cls: Any) -> Any:
337
+ """Warn when a file changed on disk after HX last read it.
338
+
339
+ This belongs in late injection, not the system prompt: the set changes
340
+ constantly, and putting it in the prefix would invalidate the cache on
341
+ every single turn.
342
+ """
343
+
344
+ def inject() -> Any:
345
+ stale = tracker.stale_files()
346
+ if not stale:
347
+ return None
348
+ listed = "\n".join(f"- {path}" for path in stale[:20])
349
+ return injection_cls(
350
+ source="stale_files",
351
+ text=(
352
+ "These files changed on disk since you last read them. "
353
+ f"Re-read before editing:\n{listed}"
354
+ ),
355
+ priority=20,
356
+ )
357
+
358
+ return inject
359
+
360
+
361
+ def _rules_from_settings(permissions: Any) -> list[Any]:
362
+ """Rules passed on the command line or via env, layered on top of the files."""
363
+ from hx.permissions.engine import Decision, parse_rule
364
+
365
+ rules: list[Any] = []
366
+ for texts, decision in (
367
+ (permissions.deny, Decision.DENY),
368
+ (permissions.ask, Decision.ASK),
369
+ (permissions.allow, Decision.ALLOW),
370
+ ):
371
+ rules.extend(parse_rule(str(text), "settings", decision) for text in texts)
372
+ return rules
373
+
374
+
375
+ def prompt_for_api_key() -> bool:
376
+ """First-run onboarding. Returns True once a key has been saved.
377
+
378
+ The key is read without echo and written straight to ``~/.hx/auth.json``
379
+ with mode 0600; it is never printed back or logged.
380
+ """
381
+ from hx.providers.openrouter import save_api_key
382
+
383
+ if not sys.stdin.isatty():
384
+ return False
385
+
386
+ print("HX needs an OpenRouter API key.")
387
+ print("Create one at https://openrouter.ai/keys, then paste it below.")
388
+ try:
389
+ key = getpass.getpass("OpenRouter API key (input hidden): ").strip()
390
+ except (EOFError, KeyboardInterrupt):
391
+ print()
392
+ return False
393
+
394
+ if not key:
395
+ return False
396
+
397
+ save_api_key(key)
398
+ print(f"Saved to {auth_file()} (mode 0600).")
399
+ return True
400
+
401
+
402
+ def run_tui_command(parsed: ParsedArgs) -> int:
403
+ """Boot the full stack - settings, provider, tools, MCP, skills - and run the TUI."""
404
+ from hx.providers.openrouter import MissingAPIKey
405
+ from hx.tui.app import run_tui
406
+
407
+ try:
408
+ runtime = build_runtime(parsed, resume=_resume_target(parsed))
409
+ except MissingAPIKey as exc:
410
+ if not prompt_for_api_key():
411
+ return _report(exc)
412
+ try:
413
+ runtime = build_runtime(parsed, resume=_resume_target(parsed))
414
+ except Exception as retry_exc:
415
+ return _report(retry_exc)
416
+ except Exception as exc:
417
+ return _report(exc)
418
+
419
+ async def main_async() -> None:
420
+ try:
421
+ await runtime.connect_mcp()
422
+ await runtime.refresh_models_if_stale()
423
+ await run_tui(
424
+ runtime.loop,
425
+ runtime.bus,
426
+ runtime.settings,
427
+ runtime.models,
428
+ runtime.api_key,
429
+ sandbox_active=runtime.sandbox_active,
430
+ sandbox_backend=runtime.sandbox_backend,
431
+ skills=runtime.skills,
432
+ agents=runtime.agents,
433
+ mcp=runtime.mcp,
434
+ )
435
+ finally:
436
+ runtime.bus.close()
437
+ await runtime.provider.aclose()
438
+
439
+ asyncio.run(main_async())
440
+ return 0
441
+
442
+
443
+ def run_print_command(parsed: ParsedArgs) -> int:
444
+ """Headless single-prompt run. Streams assistant text to stdout and tool
445
+ activity to stderr, so stdout stays pipeable."""
446
+ from hx.core import events as ev
447
+
448
+ try:
449
+ runtime = build_runtime(parsed, resume=_resume_target(parsed))
450
+ except Exception as exc:
451
+ return _report(exc)
452
+
453
+ async def main_async() -> int:
454
+ async def render() -> None:
455
+ async for event in runtime.bus.subscribe():
456
+ match event:
457
+ case ev.TextDelta():
458
+ sys.stdout.write(event.text)
459
+ sys.stdout.flush()
460
+ case ev.ToolCallStarted():
461
+ print(f"[tool] {event.name} {event.input}", file=sys.stderr)
462
+ case ev.PermissionRequested():
463
+ print(f"[permission] {event.description}", file=sys.stderr)
464
+ case ev.ToolCallFinished():
465
+ marker = "error" if event.is_error else "ok"
466
+ print(f"[tool] {marker} ({event.duration_ms:.0f}ms)", file=sys.stderr)
467
+ case ev.ErrorRaised():
468
+ print(f"[error] {event.message}", file=sys.stderr)
469
+
470
+ renderer = asyncio.create_task(render())
471
+ await asyncio.sleep(0)
472
+ for status in await runtime.connect_mcp():
473
+ if not status.connected:
474
+ print(f"[mcp] {status.name} unavailable: {status.error}", file=sys.stderr)
475
+ try:
476
+ result = await runtime.loop.run(parsed.prompt)
477
+ finally:
478
+ await asyncio.sleep(0.05)
479
+ runtime.bus.close()
480
+ await renderer
481
+ await runtime.aclose()
482
+
483
+ print()
484
+ usage = runtime.session.usage
485
+ print(
486
+ f"[usage] in={usage.total_input} out={usage.total_output} "
487
+ f"cache_read={usage.total_cache_read} cache_write={usage.total_cache_write} "
488
+ f"cost=${usage.total_cost_usd:.4f}",
489
+ file=sys.stderr,
490
+ )
491
+ return 1 if result.error else 0
492
+
493
+ return asyncio.run(main_async())
494
+
495
+
496
+ def _resume_target(parsed: ParsedArgs) -> str | None:
497
+ """Resolve ``hx resume [id]`` to a session id, newest-first when omitted."""
498
+ if parsed.command != "resume":
499
+ return None
500
+ if parsed.session_id:
501
+ return parsed.session_id
502
+
503
+ from hx.config import load_settings
504
+ from hx.core.session import latest_session
505
+
506
+ settings = load_settings(parsed.cwd, parsed.overrides)
507
+ meta = latest_session(settings.cwd)
508
+ if meta is None:
509
+ raise UsageError("no previous session in this directory")
510
+ return meta.session_id
511
+
512
+
513
+ MCP_USAGE = """\
514
+ hx mcp list Show configured servers
515
+ hx mcp add NAME COMMAND [ARGS...] Add a stdio server
516
+ hx mcp add NAME --url URL Add an HTTP server
517
+ hx mcp remove NAME Remove a server
518
+
519
+ Add --user to write to ~/.hx/mcp.json instead of the project's .hx/mcp.json.
520
+ """
521
+
522
+
523
+ def run_mcp_command(args: list[str]) -> int:
524
+ from hx.mcp.manager import (
525
+ MCPServerConfig,
526
+ load_configs,
527
+ remove_config,
528
+ save_config,
529
+ )
530
+
531
+ user_level = "--user" in args
532
+ args = [arg for arg in args if arg != "--user"]
533
+ cwd = Path.cwd()
534
+
535
+ if not args or args[0] == "list":
536
+ configs = load_configs(cwd)
537
+ if not configs:
538
+ print("No MCP servers configured.\n")
539
+ print(MCP_USAGE)
540
+ return 0
541
+ statuses = asyncio.run(_probe_servers(configs))
542
+ for status in statuses:
543
+ mark = "ok" if status.connected else "unavailable"
544
+ detail = f" - {status.error}" if status.error else f" ({status.tool_count} tools)"
545
+ print(f"{status.name:<20} {mark}{detail}")
546
+ return 0
547
+
548
+ if args[0] == "add":
549
+ if len(args) < 3:
550
+ print(MCP_USAGE, file=sys.stderr)
551
+ return 2
552
+ name = args[1]
553
+ if args[2] == "--url":
554
+ config = MCPServerConfig(name=name, transport="http", url=args[3])
555
+ else:
556
+ config = MCPServerConfig(
557
+ name=name, transport="stdio", command=args[2], args=tuple(args[3:])
558
+ )
559
+ print(f"Added {name} to {save_config(config, cwd, user_level)}")
560
+ return 0
561
+
562
+ if args[0] == "remove":
563
+ if len(args) < 2:
564
+ print(MCP_USAGE, file=sys.stderr)
565
+ return 2
566
+ if remove_config(args[1], cwd, user_level):
567
+ print(f"Removed {args[1]}.")
568
+ return 0
569
+ print(f"No server named {args[1]}.", file=sys.stderr)
570
+ return 1
571
+
572
+ print(MCP_USAGE, file=sys.stderr)
573
+ return 2
574
+
575
+
576
+ async def _probe_servers(configs: list[Any]) -> list[Any]:
577
+ from hx.mcp.manager import MCPManager
578
+
579
+ manager = MCPManager(configs)
580
+ try:
581
+ return await manager.connect_all()
582
+ finally:
583
+ await manager.close_all()
584
+
585
+
586
+ AUTH_USAGE = """\
587
+ hx auth Show whether a key is set, and where it comes from
588
+ hx auth set Paste a new key (hidden) and save it to ~/.hx/auth.json
589
+ hx auth clear Remove the saved key
590
+
591
+ The environment (HX_OPENROUTER_API_KEY, then OPENROUTER_API_KEY) takes
592
+ precedence over the saved file.
593
+ """
594
+
595
+
596
+ def run_auth_command(args: list[str]) -> int:
597
+ """Show or change the stored OpenRouter key.
598
+
599
+ The TUI has /configure; this is the same thing for a headless machine,
600
+ where there is no interface to prompt from mid-session.
601
+ """
602
+ import json
603
+
604
+ from hx.providers.openrouter import api_key_source, load_api_key, mask_api_key
605
+
606
+ action = args[0] if args else "status"
607
+
608
+ if action == "status":
609
+ source = api_key_source()
610
+ if source is None:
611
+ print("No OpenRouter API key set. Run `hx auth set`.")
612
+ return 1
613
+ print(f"Key {mask_api_key(load_api_key())} from {source}")
614
+ if source.startswith("environment"):
615
+ print("Note: the environment overrides anything saved in ~/.hx/auth.json.")
616
+ return 0
617
+
618
+ if action == "set":
619
+ if not prompt_for_api_key():
620
+ print("No key entered.", file=sys.stderr)
621
+ return 1
622
+ return 0
623
+
624
+ if action == "clear":
625
+ path = auth_file()
626
+ if not path.is_file():
627
+ print("No saved key to remove.")
628
+ return 0
629
+ try:
630
+ data = json.loads(path.read_text())
631
+ except (OSError, json.JSONDecodeError):
632
+ data = {}
633
+ data.pop("openrouter_api_key", None)
634
+ path.write_text(json.dumps(data, indent=2))
635
+ path.chmod(0o600)
636
+ print(f"Removed the saved key from {path}.")
637
+ return 0
638
+
639
+ print(AUTH_USAGE, file=sys.stderr)
640
+ return 2
641
+
642
+
643
+ def run_upgrade_command() -> int:
644
+ """Wraps ``uv tool upgrade hx``."""
645
+ try:
646
+ completed = subprocess.run(["uv", "tool", "upgrade", "hx-cli"], check=False)
647
+ except FileNotFoundError:
648
+ print("uv is not installed. See https://docs.astral.sh/uv/", file=sys.stderr)
649
+ return 1
650
+ return completed.returncode
651
+
652
+
653
+ def _report(exc: Exception) -> int:
654
+ from hx.providers.openrouter import MissingAPIKey
655
+
656
+ if isinstance(exc, MissingAPIKey | UsageError):
657
+ print(f"error: {exc}", file=sys.stderr)
658
+ return 2
659
+ raise exc
660
+
661
+
662
+ class UsageError(Exception):
663
+ pass
664
+
665
+
666
+ if __name__ == "__main__":
667
+ raise SystemExit(main())