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.
- hx/__init__.py +5 -0
- hx/agents/__init__.py +1 -0
- hx/agents/definitions.py +106 -0
- hx/agents/subagent.py +190 -0
- hx/cli.py +667 -0
- hx/config.py +277 -0
- hx/core/__init__.py +1 -0
- hx/core/compaction.py +245 -0
- hx/core/context.py +271 -0
- hx/core/events.py +183 -0
- hx/core/lateinject.py +121 -0
- hx/core/loop.py +537 -0
- hx/core/messages.py +164 -0
- hx/core/session.py +208 -0
- hx/core/usage.py +129 -0
- hx/frontmatter.py +80 -0
- hx/mcp/__init__.py +1 -0
- hx/mcp/client.py +319 -0
- hx/mcp/manager.py +265 -0
- hx/paths.py +90 -0
- hx/permissions/__init__.py +7 -0
- hx/permissions/engine.py +406 -0
- hx/permissions/parser.py +306 -0
- hx/permissions/sandbox.py +227 -0
- hx/providers/__init__.py +1 -0
- hx/providers/base.py +77 -0
- hx/providers/fake.py +87 -0
- hx/providers/models.py +238 -0
- hx/providers/openrouter.py +468 -0
- hx/skills/__init__.py +1 -0
- hx/skills/loader.py +102 -0
- hx/skills/runtime.py +84 -0
- hx/tools/__init__.py +1 -0
- hx/tools/base.py +97 -0
- hx/tools/bash.py +544 -0
- hx/tools/edit.py +167 -0
- hx/tools/glob.py +75 -0
- hx/tools/grep.py +165 -0
- hx/tools/output.py +133 -0
- hx/tools/read.py +142 -0
- hx/tools/registry.py +149 -0
- hx/tools/task.py +76 -0
- hx/tools/todo.py +149 -0
- hx/tools/write.py +87 -0
- hx/tui/__init__.py +1 -0
- hx/tui/app.py +487 -0
- hx/tui/commands.py +399 -0
- hx/tui/hx.tcss +197 -0
- hx/tui/renderers.py +570 -0
- hx/tui/theme.py +322 -0
- hx/tui/widgets/__init__.py +1 -0
- hx/tui/widgets/configure.py +95 -0
- hx/tui/widgets/diff.py +25 -0
- hx/tui/widgets/input.py +145 -0
- hx/tui/widgets/palette.py +130 -0
- hx/tui/widgets/permission.py +97 -0
- hx/tui/widgets/statusbar.py +212 -0
- hx/tui/widgets/todos.py +116 -0
- hx/tui/widgets/transcript.py +316 -0
- hx/tui/widgets/working.py +78 -0
- hx_cli-0.1.0.dist-info/METADATA +430 -0
- hx_cli-0.1.0.dist-info/RECORD +64 -0
- hx_cli-0.1.0.dist-info/WHEEL +4 -0
- 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())
|