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/core/session.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Session persistence: append-only JSONL transcripts.
|
|
2
|
+
|
|
3
|
+
Every message, tool result, usage record and compaction boundary is appended as
|
|
4
|
+
it happens, so a crashed session is fully resumable and a compaction can be
|
|
5
|
+
inspected or undone after the fact.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from hx.core.messages import Message, from_dict, to_dict
|
|
18
|
+
from hx.core.usage import TurnUsage, UsageLedger
|
|
19
|
+
from hx.paths import (
|
|
20
|
+
session_dir,
|
|
21
|
+
session_outputs_dir,
|
|
22
|
+
session_transcript_file,
|
|
23
|
+
sessions_dir,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class SessionMeta:
|
|
29
|
+
session_id: str
|
|
30
|
+
cwd: str
|
|
31
|
+
created_at: float
|
|
32
|
+
updated_at: float
|
|
33
|
+
model: str
|
|
34
|
+
title: str | None = None
|
|
35
|
+
message_count: int = 0
|
|
36
|
+
parent_id: str | None = None
|
|
37
|
+
"""Set for subagent sessions, which nest under their parent's directory."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class Session:
|
|
42
|
+
"""In-memory session state, mirrored to ``~/.hx/sessions/<id>/transcript.jsonl``."""
|
|
43
|
+
|
|
44
|
+
meta: SessionMeta
|
|
45
|
+
messages: list[Message] = field(default_factory=list)
|
|
46
|
+
usage: UsageLedger = field(default_factory=UsageLedger)
|
|
47
|
+
_pending: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
48
|
+
|
|
49
|
+
def append(self, message: Message) -> None:
|
|
50
|
+
"""Add to memory and flush the JSONL line. Ephemeral messages are recorded
|
|
51
|
+
but marked so resume does not replay stale reminders."""
|
|
52
|
+
self.messages.append(message)
|
|
53
|
+
self.meta.message_count = len(self.messages)
|
|
54
|
+
self.meta.updated_at = time.time()
|
|
55
|
+
self._pending.append({"kind": "message", "data": to_dict(message)})
|
|
56
|
+
self.flush()
|
|
57
|
+
|
|
58
|
+
def record_compaction(
|
|
59
|
+
self,
|
|
60
|
+
dropped: list[Message],
|
|
61
|
+
kept: list[Message],
|
|
62
|
+
summary: Message,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Retire the pre-compaction history and re-lay it as summary + kept.
|
|
65
|
+
|
|
66
|
+
The transcript is append-only, so the summary cannot be spliced in
|
|
67
|
+
ahead of the messages it replaces. Instead every live message is
|
|
68
|
+
flagged compacted and the new sequence is appended in order. Nothing is
|
|
69
|
+
deleted, so a resume replays exactly what happened and a future undo
|
|
70
|
+
still has the original turns to restore.
|
|
71
|
+
"""
|
|
72
|
+
superseded = [*dropped, *kept]
|
|
73
|
+
for message in superseded:
|
|
74
|
+
message.compacted = True
|
|
75
|
+
|
|
76
|
+
self._pending.append({"kind": "compaction", "data": {"count": len(superseded)}})
|
|
77
|
+
self.append(summary)
|
|
78
|
+
for message in kept:
|
|
79
|
+
self.append(replace(message, compacted=False))
|
|
80
|
+
|
|
81
|
+
def record_usage(self, usage: TurnUsage) -> None:
|
|
82
|
+
self.usage.record(usage)
|
|
83
|
+
self._pending.append({"kind": "usage", "data": asdict(usage)})
|
|
84
|
+
|
|
85
|
+
def active_messages(self) -> list[Message]:
|
|
86
|
+
"""Messages eligible for context: not ``compacted``, not ``ephemeral``."""
|
|
87
|
+
return [m for m in self.messages if not m.compacted and not m.ephemeral]
|
|
88
|
+
|
|
89
|
+
def flush(self) -> None:
|
|
90
|
+
if not self._pending:
|
|
91
|
+
return
|
|
92
|
+
path = session_transcript_file(self.meta.session_id)
|
|
93
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
95
|
+
for record in self._pending:
|
|
96
|
+
handle.write(json.dumps(record) + "\n")
|
|
97
|
+
self._pending.clear()
|
|
98
|
+
self._write_meta()
|
|
99
|
+
|
|
100
|
+
def _write_meta(self) -> None:
|
|
101
|
+
path = session_dir(self.meta.session_id) / "meta.json"
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
path.write_text(json.dumps(asdict(self.meta), indent=2))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def new_session(cwd: Path, model: str, parent_id: str | None = None) -> Session:
|
|
107
|
+
"""Create a session with a fresh id and its on-disk directory.
|
|
108
|
+
|
|
109
|
+
A subagent session nests under its parent (``<parent>/sub-<id>``), which
|
|
110
|
+
keeps its transcript alongside the work it belongs to and keeps it out of
|
|
111
|
+
the ``/resume`` listing, where it would be noise.
|
|
112
|
+
"""
|
|
113
|
+
now = time.time()
|
|
114
|
+
session_id = f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now))}-{uuid.uuid4().hex[:8]}"
|
|
115
|
+
if parent_id:
|
|
116
|
+
if ".." in parent_id or parent_id.startswith("/"):
|
|
117
|
+
raise ValueError(f"unsafe parent session id: {parent_id!r}")
|
|
118
|
+
session_id = f"{parent_id}/sub-{uuid.uuid4().hex[:8]}"
|
|
119
|
+
|
|
120
|
+
meta = SessionMeta(
|
|
121
|
+
session_id=session_id,
|
|
122
|
+
cwd=str(cwd.resolve()),
|
|
123
|
+
created_at=now,
|
|
124
|
+
updated_at=now,
|
|
125
|
+
model=model,
|
|
126
|
+
parent_id=parent_id,
|
|
127
|
+
)
|
|
128
|
+
session_outputs_dir(session_id).mkdir(parents=True, exist_ok=True)
|
|
129
|
+
session = Session(meta=meta)
|
|
130
|
+
session._write_meta()
|
|
131
|
+
return session
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_session(session_id: str) -> Session:
|
|
135
|
+
"""Rehydrate from JSONL.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
SessionNotFound: if the id has no transcript.
|
|
139
|
+
"""
|
|
140
|
+
path = session_transcript_file(session_id)
|
|
141
|
+
meta_path = session_dir(session_id) / "meta.json"
|
|
142
|
+
if not meta_path.exists():
|
|
143
|
+
raise SessionNotFound(session_id)
|
|
144
|
+
|
|
145
|
+
meta = SessionMeta(**json.loads(meta_path.read_text()))
|
|
146
|
+
session = Session(meta=meta)
|
|
147
|
+
if not path.exists():
|
|
148
|
+
return session
|
|
149
|
+
|
|
150
|
+
with path.open(encoding="utf-8") as handle:
|
|
151
|
+
for line in handle:
|
|
152
|
+
line = line.strip()
|
|
153
|
+
if not line:
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
record = json.loads(line)
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
# A torn final line from a crashed write costs one record, not the session.
|
|
159
|
+
continue
|
|
160
|
+
kind = record.get("kind")
|
|
161
|
+
if kind == "message":
|
|
162
|
+
session.messages.append(from_dict(record["data"]))
|
|
163
|
+
elif kind == "usage":
|
|
164
|
+
session.usage.record(TurnUsage(**record["data"]))
|
|
165
|
+
elif kind == "compaction":
|
|
166
|
+
# Compaction always supersedes a prefix of the live messages,
|
|
167
|
+
# so replaying in order reproduces the same partition.
|
|
168
|
+
remaining = int(record["data"]["count"])
|
|
169
|
+
for message in session.messages:
|
|
170
|
+
if remaining <= 0:
|
|
171
|
+
break
|
|
172
|
+
if not message.compacted:
|
|
173
|
+
message.compacted = True
|
|
174
|
+
remaining -= 1
|
|
175
|
+
session.meta.message_count = len(session.messages)
|
|
176
|
+
return session
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def list_sessions(cwd: Path | None = None, limit: int = 20) -> list[SessionMeta]:
|
|
180
|
+
"""Most-recent-first. Filtered to ``cwd`` when given - powers ``/resume``."""
|
|
181
|
+
root = sessions_dir()
|
|
182
|
+
if not root.is_dir():
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
metas: list[SessionMeta] = []
|
|
186
|
+
for entry in root.iterdir():
|
|
187
|
+
meta_path = entry / "meta.json"
|
|
188
|
+
if not meta_path.is_file():
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
meta = SessionMeta(**json.loads(meta_path.read_text()))
|
|
192
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
193
|
+
continue
|
|
194
|
+
if cwd is not None and meta.cwd != str(cwd.resolve()):
|
|
195
|
+
continue
|
|
196
|
+
metas.append(meta)
|
|
197
|
+
|
|
198
|
+
metas.sort(key=lambda m: m.updated_at, reverse=True)
|
|
199
|
+
return metas[:limit]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def latest_session(cwd: Path) -> SessionMeta | None:
|
|
203
|
+
found = list_sessions(cwd, limit=1)
|
|
204
|
+
return found[0] if found else None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class SessionNotFound(Exception):
|
|
208
|
+
pass
|
hx/core/usage.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Token and cost ledger.
|
|
2
|
+
|
|
3
|
+
Feeds the status bar fields: context used, tokens used, cache-read/cache-write
|
|
4
|
+
tokens, and session cost. OpenRouter returns an authoritative ``usage.cost``
|
|
5
|
+
when ``usage: {include: true}`` is set on the request; we prefer that over our
|
|
6
|
+
own arithmetic and only compute locally as a fallback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # avoids a providers -> core import cycle at runtime
|
|
15
|
+
from hx.providers.models import ModelPricing
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class TurnUsage:
|
|
20
|
+
"""Usage for a single provider call."""
|
|
21
|
+
|
|
22
|
+
input_tokens: int = 0
|
|
23
|
+
"""Prompt tokens billed at the full rate. Excludes ``cache_read_tokens``."""
|
|
24
|
+
output_tokens: int = 0
|
|
25
|
+
cache_read_tokens: int = 0
|
|
26
|
+
"""Prompt tokens served from the provider's KV cache (billed at a discount)."""
|
|
27
|
+
cache_write_tokens: int = 0
|
|
28
|
+
"""Prompt tokens written into the cache (billed at a premium on Anthropic)."""
|
|
29
|
+
reasoning_tokens: int = 0
|
|
30
|
+
cost_usd: float | None = None
|
|
31
|
+
"""Provider-reported cost. ``None`` means fall back to local pricing."""
|
|
32
|
+
latency_ms: float = 0.0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def prompt_tokens(self) -> int:
|
|
36
|
+
"""Every prompt token, cached or not."""
|
|
37
|
+
return self.input_tokens + self.cache_read_tokens
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def cache_hit_rate(self) -> float:
|
|
41
|
+
"""Cached share of prompt tokens, 0.0-1.0."""
|
|
42
|
+
total = self.prompt_tokens
|
|
43
|
+
return self.cache_read_tokens / total if total else 0.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class UsageLedger:
|
|
48
|
+
"""Running totals for a session."""
|
|
49
|
+
|
|
50
|
+
turns: list[TurnUsage] = field(default_factory=list)
|
|
51
|
+
context_tokens: int = 0
|
|
52
|
+
"""Tokens currently occupied by the assembled context."""
|
|
53
|
+
context_window: int = 0
|
|
54
|
+
|
|
55
|
+
def record(self, usage: TurnUsage) -> None:
|
|
56
|
+
self.turns.append(usage)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def total_input(self) -> int:
|
|
60
|
+
return sum(t.input_tokens for t in self.turns)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def total_output(self) -> int:
|
|
64
|
+
return sum(t.output_tokens for t in self.turns)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def total_cache_read(self) -> int:
|
|
68
|
+
return sum(t.cache_read_tokens for t in self.turns)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def total_cache_write(self) -> int:
|
|
72
|
+
return sum(t.cache_write_tokens for t in self.turns)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def total_cost_usd(self) -> float:
|
|
76
|
+
return sum(t.cost_usd or 0.0 for t in self.turns)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def cache_hit_rate(self) -> float:
|
|
80
|
+
prompt = self.total_input + self.total_cache_read
|
|
81
|
+
return self.total_cache_read / prompt if prompt else 0.0
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def last_latency_ms(self) -> float:
|
|
85
|
+
return self.turns[-1].latency_ms if self.turns else 0.0
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def context_fraction(self) -> float:
|
|
89
|
+
"""``context_tokens / context_window``, clamped to 0.0-1.0. Drives the
|
|
90
|
+
compaction trigger and the status bar gauge."""
|
|
91
|
+
if self.context_window <= 0:
|
|
92
|
+
return 0.0
|
|
93
|
+
return min(1.0, max(0.0, self.context_tokens / self.context_window))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compute_cost(usage: TurnUsage, pricing: ModelPricing) -> float:
|
|
97
|
+
"""Local cost fallback when the provider reports none.
|
|
98
|
+
|
|
99
|
+
Cache reads and writes are priced separately from ordinary input tokens, and
|
|
100
|
+
``input_tokens`` deliberately *excludes* cached tokens - double-counting here
|
|
101
|
+
silently inflates every cost display.
|
|
102
|
+
"""
|
|
103
|
+
cache_read_rate = pricing.cache_read or pricing.prompt
|
|
104
|
+
cache_write_rate = pricing.cache_write or pricing.prompt
|
|
105
|
+
return (
|
|
106
|
+
usage.input_tokens * pricing.prompt
|
|
107
|
+
+ usage.output_tokens * pricing.completion
|
|
108
|
+
+ usage.cache_read_tokens * cache_read_rate
|
|
109
|
+
+ usage.cache_write_tokens * cache_write_rate
|
|
110
|
+
+ usage.reasoning_tokens * pricing.reasoning
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_cost(cost_usd: float) -> str:
|
|
115
|
+
"""Human-readable cost for the status bar (e.g. ``$0.0431``)."""
|
|
116
|
+
if cost_usd >= 100:
|
|
117
|
+
return f"${cost_usd:,.0f}"
|
|
118
|
+
if cost_usd >= 1:
|
|
119
|
+
return f"${cost_usd:.2f}"
|
|
120
|
+
return f"${cost_usd:.4f}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def format_tokens(count: int) -> str:
|
|
124
|
+
"""Compact token count (e.g. ``12.4k``, ``1.2M``)."""
|
|
125
|
+
if count < 1_000:
|
|
126
|
+
return str(count)
|
|
127
|
+
if count < 1_000_000:
|
|
128
|
+
return f"{count / 1_000:.1f}k".replace(".0k", "k")
|
|
129
|
+
return f"{count / 1_000_000:.1f}M".replace(".0M", "M")
|
hx/frontmatter.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""YAML frontmatter parsing, shared by skills and agent definitions.
|
|
2
|
+
|
|
3
|
+
Both are Markdown files whose first block is ``---``-delimited YAML. Keeping
|
|
4
|
+
one parser means a malformed file fails the same way in both places.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
DELIMITER = "---"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class Document:
|
|
20
|
+
metadata: dict[str, Any]
|
|
21
|
+
body: str
|
|
22
|
+
path: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse(text: str, path: Path) -> Document:
|
|
26
|
+
"""Split frontmatter from body.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
FrontmatterError: when the block is unterminated or is not a mapping.
|
|
30
|
+
A file that silently parses as "no metadata" would be discovered
|
|
31
|
+
and then quietly ignored, which is harder to debug than a failure.
|
|
32
|
+
"""
|
|
33
|
+
stripped = text.lstrip("")
|
|
34
|
+
if not stripped.startswith(DELIMITER):
|
|
35
|
+
return Document(metadata={}, body=stripped.strip(), path=path)
|
|
36
|
+
|
|
37
|
+
lines = stripped.splitlines()
|
|
38
|
+
closing = next(
|
|
39
|
+
(i for i, line in enumerate(lines[1:], start=1) if line.strip() == DELIMITER), None
|
|
40
|
+
)
|
|
41
|
+
if closing is None:
|
|
42
|
+
raise FrontmatterError(f"{path}: frontmatter opened with --- but never closed")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
metadata = yaml.safe_load("\n".join(lines[1:closing])) or {}
|
|
46
|
+
except yaml.YAMLError as exc:
|
|
47
|
+
raise FrontmatterError(f"{path}: invalid YAML frontmatter ({exc})") from exc
|
|
48
|
+
|
|
49
|
+
if not isinstance(metadata, dict):
|
|
50
|
+
raise FrontmatterError(f"{path}: frontmatter must be a mapping")
|
|
51
|
+
|
|
52
|
+
return Document(metadata=metadata, body="\n".join(lines[closing + 1 :]).strip(), path=path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read(path: Path) -> Document:
|
|
56
|
+
try:
|
|
57
|
+
return parse(path.read_text(encoding="utf-8"), path)
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise FrontmatterError(f"{path}: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require(document: Document, *keys: str) -> None:
|
|
63
|
+
missing = [key for key in keys if not str(document.metadata.get(key, "")).strip()]
|
|
64
|
+
if missing:
|
|
65
|
+
raise FrontmatterError(f"{document.path}: missing required field(s): {', '.join(missing)}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def string_tuple(value: Any) -> tuple[str, ...]:
|
|
69
|
+
"""Accept either a YAML list or a comma-separated string."""
|
|
70
|
+
if value is None:
|
|
71
|
+
return ()
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
return tuple(str(item).strip() for item in value if str(item).strip())
|
|
76
|
+
return ()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FrontmatterError(Exception):
|
|
80
|
+
pass
|
hx/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Model Context Protocol client: external tool servers."""
|