loom-code 0.1.1__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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/extensions.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""User-extensibility discovery for loom-code (the ``.loom`` folder).
|
|
2
|
+
|
|
3
|
+
loom-code mirrors Claude Code's ``.claude`` folder: users drop in
|
|
4
|
+
their own skills, subagents, and hooks and loom-code picks them up.
|
|
5
|
+
There are THREE layers, lowest priority first:
|
|
6
|
+
|
|
7
|
+
bundled ``loom_code/skills/`` (shipped)
|
|
8
|
+
user ``~/.loom-code/{skills,agents}/`` (your global config)
|
|
9
|
+
project ``<repo>/.loom/{skills,agents}/`` (this repo only)
|
|
10
|
+
|
|
11
|
+
The user + project layers also carry a ``settings.toml`` declaring
|
|
12
|
+
hooks.
|
|
13
|
+
|
|
14
|
+
:func:`discover` scans the user + project layers and returns one
|
|
15
|
+
:class:`Extensions` bundle. ``build_agent`` calls it once and threads
|
|
16
|
+
the three results into the existing wiring:
|
|
17
|
+
|
|
18
|
+
* **skills** — appended to the bundled skill list passed to every
|
|
19
|
+
agent. The framework's ``SkillRegistry`` does last-source-wins by
|
|
20
|
+
name, so we append user *then* project (project wins on collision).
|
|
21
|
+
* **agents** — parsed into :class:`AgentSpec` and merged into the
|
|
22
|
+
``Team.supervisor`` worker roster (project wins on name collision).
|
|
23
|
+
* **hooks** — parsed into :class:`HookSpec`. Tool-lifecycle hooks
|
|
24
|
+
(PreToolUse/PostToolUse/Stop) become framework hooks on the
|
|
25
|
+
tool-executing agents; REPL-lifecycle hooks (UserPromptSubmit/
|
|
26
|
+
SessionStart/SessionEnd) the REPL fires itself. Hooks are
|
|
27
|
+
**additive** across scopes — a project cannot disable your personal
|
|
28
|
+
hooks.
|
|
29
|
+
|
|
30
|
+
Parsing is dependency-free on purpose: loom-code does not depend on
|
|
31
|
+
pyyaml, so frontmatter is split by a tiny in-house parser
|
|
32
|
+
(:func:`_parse_frontmatter`) that handles the handful of fields a
|
|
33
|
+
subagent declares, and ``settings.toml`` is read with stdlib
|
|
34
|
+
``tomllib``.
|
|
35
|
+
|
|
36
|
+
This module is pure discovery + parsing. It does NOT execute hooks,
|
|
37
|
+
construct Agents, or prompt for trust — those live with the code that
|
|
38
|
+
consumes the specs (``workers.py`` for agents, ``hooks.py`` for the
|
|
39
|
+
shim + trust gate).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import re
|
|
45
|
+
import tomllib
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import TYPE_CHECKING
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
# Type-only import: the annotation is a string under
|
|
52
|
+
# ``from __future__ import annotations``, so this never loads the
|
|
53
|
+
# ``mcp`` extra at runtime. The actual spec is constructed lazily in
|
|
54
|
+
# ``_discover_mcp`` where the import is genuinely needed.
|
|
55
|
+
from loomflow.mcp import MCPServerSpec
|
|
56
|
+
|
|
57
|
+
# The two scope roots. ``.loom`` (project) matches ``agent.LOOM_DIR``;
|
|
58
|
+
# ``.loom-code`` (user, with the hyphen) matches
|
|
59
|
+
# ``credentials._CREDENTIALS_DIR`` — the hyphen is the deliberate
|
|
60
|
+
# "this is GLOBAL config, not a project" differentiator.
|
|
61
|
+
PROJECT_DIRNAME = ".loom"
|
|
62
|
+
USER_DIRNAME = ".loom-code"
|
|
63
|
+
|
|
64
|
+
# Hook events loom-code recognises. Tool-lifecycle events map onto the
|
|
65
|
+
# framework's HookRegistry / stop_hooks; REPL-lifecycle events the REPL
|
|
66
|
+
# fires directly (the framework has no hook point for them).
|
|
67
|
+
TOOL_HOOK_EVENTS = frozenset({"PreToolUse", "PostToolUse"})
|
|
68
|
+
STOP_HOOK_EVENTS = frozenset({"Stop"})
|
|
69
|
+
REPL_HOOK_EVENTS = frozenset(
|
|
70
|
+
{"UserPromptSubmit", "SessionStart", "SessionEnd"}
|
|
71
|
+
)
|
|
72
|
+
KNOWN_HOOK_EVENTS = TOOL_HOOK_EVENTS | STOP_HOOK_EVENTS | REPL_HOOK_EVENTS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class AgentSpec:
|
|
77
|
+
"""A user-authored subagent parsed from ``<scope>/agents/<name>.md``.
|
|
78
|
+
|
|
79
|
+
The markdown body is the subagent's system prompt; the frontmatter
|
|
80
|
+
carries the routing contract (``name`` + ``description`` — the
|
|
81
|
+
supervisor delegates by description) and optional ``model`` /
|
|
82
|
+
``tools`` overrides.
|
|
83
|
+
|
|
84
|
+
``tools`` is the list of builtin tool names the subagent may use
|
|
85
|
+
(``read``/``write``/``edit``/``multi_edit``/``grep``/``find``/``ls``/
|
|
86
|
+
``bash``/``web_fetch``). Empty means "unspecified" — the wiring
|
|
87
|
+
applies a read-only default rather than handing a stranger's spec
|
|
88
|
+
write access implicitly.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
description: str
|
|
93
|
+
system_prompt: str
|
|
94
|
+
model: str | None = None
|
|
95
|
+
tools: tuple[str, ...] = ()
|
|
96
|
+
source: str = "project" # "user" | "project"
|
|
97
|
+
path: Path | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class HookSpec:
|
|
102
|
+
"""A hook parsed from a ``[[hooks]]`` entry in ``settings.toml``.
|
|
103
|
+
|
|
104
|
+
``event`` is one of :data:`KNOWN_HOOK_EVENTS`. ``matcher`` is a
|
|
105
|
+
tool-name pattern (only meaningful for tool-lifecycle events): the
|
|
106
|
+
literal ``"*"`` / ``""`` matches all, a pipe-separated list like
|
|
107
|
+
``"bash|edit"`` matches any of those tools, anything else is a
|
|
108
|
+
regex. ``command`` is the shell command run with the event's JSON
|
|
109
|
+
on stdin. ``source`` records which scope declared it — user-scope
|
|
110
|
+
hooks are trusted; project-scope hooks are trust-gated.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
event: str
|
|
114
|
+
command: str
|
|
115
|
+
matcher: str = "*"
|
|
116
|
+
timeout: float = 60.0
|
|
117
|
+
source: str = "project" # "user" | "project"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class McpEntry:
|
|
122
|
+
"""An MCP server declared in a ``[[mcp]]`` block of ``settings.toml``.
|
|
123
|
+
|
|
124
|
+
``source`` is "user" or "project"; the trust gate keys off it exactly
|
|
125
|
+
as it does for :class:`HookSpec` — a project-declared server from an
|
|
126
|
+
untrusted repo is dropped, a user-scope server is your own config and
|
|
127
|
+
always kept (connecting an MCP server runs external code / hits an
|
|
128
|
+
external endpoint). ``spec`` is the framework's ``MCPServerSpec``,
|
|
129
|
+
built lazily in :func:`_discover_mcp` so importing this module never
|
|
130
|
+
requires the ``mcp`` extra.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
source: str
|
|
134
|
+
spec: MCPServerSpec
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Extensions:
|
|
139
|
+
"""Everything discovered across the user + project layers.
|
|
140
|
+
|
|
141
|
+
``skill_paths`` are individual skill *directories* (each holds a
|
|
142
|
+
``SKILL.md``), ordered user-then-project so the framework's
|
|
143
|
+
last-source-wins resolution gives project skills priority. The
|
|
144
|
+
caller prepends the bundled skills.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
skill_paths: list[Path] = field(default_factory=list)
|
|
148
|
+
agent_specs: list[AgentSpec] = field(default_factory=list)
|
|
149
|
+
hook_specs: list[HookSpec] = field(default_factory=list)
|
|
150
|
+
# MCP servers declared in settings.toml [[mcp]] blocks, each tagged
|
|
151
|
+
# with its source ("user" | "project") so the trust gate can drop
|
|
152
|
+
# project-declared servers from an untrusted repo — same posture as
|
|
153
|
+
# project hooks (connecting an MCP server runs external code / hits
|
|
154
|
+
# an external endpoint, so a cloned repo must not auto-connect one).
|
|
155
|
+
mcp_specs: list[McpEntry] = field(default_factory=list)
|
|
156
|
+
|
|
157
|
+
def has_any(self) -> bool:
|
|
158
|
+
return bool(
|
|
159
|
+
self.skill_paths
|
|
160
|
+
or self.agent_specs
|
|
161
|
+
or self.hook_specs
|
|
162
|
+
or self.mcp_specs
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def safe_role_name(name: str) -> str:
|
|
167
|
+
"""Map a subagent's authored ``name`` to a valid worker-role id.
|
|
168
|
+
|
|
169
|
+
loomflow's worker registry requires role names to be Python
|
|
170
|
+
identifiers (no hyphens/spaces), but Claude-Code-style subagent
|
|
171
|
+
names are lowercase-with-hyphens. We translate any run of
|
|
172
|
+
non-identifier characters to a single underscore so
|
|
173
|
+
``security-auditor.md`` becomes the delegate role
|
|
174
|
+
``security_auditor``. A name that would start with a digit is
|
|
175
|
+
prefixed; an empty result falls back to ``"subagent"``."""
|
|
176
|
+
safe = re.sub(r"\W+", "_", name.strip()).strip("_")
|
|
177
|
+
if not safe:
|
|
178
|
+
return "subagent"
|
|
179
|
+
if safe[0].isdigit():
|
|
180
|
+
safe = f"a_{safe}"
|
|
181
|
+
return safe
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def discover(
|
|
185
|
+
project_root: Path,
|
|
186
|
+
*,
|
|
187
|
+
user_dir: Path | None = None,
|
|
188
|
+
) -> Extensions:
|
|
189
|
+
"""Scan the user + project layers and return the merged bundle.
|
|
190
|
+
|
|
191
|
+
``project_root`` is the repo root (``<root>/.loom/`` is scanned).
|
|
192
|
+
``user_dir`` overrides the user scope root (``~/.loom-code/`` by
|
|
193
|
+
default) — tests pass a tmp dir here.
|
|
194
|
+
|
|
195
|
+
Merge rules differ by type:
|
|
196
|
+
|
|
197
|
+
* skills — user dirs then project dirs (caller prepends bundled);
|
|
198
|
+
collisions resolved later by the framework (project wins).
|
|
199
|
+
* agents — project overrides user on duplicate ``name``.
|
|
200
|
+
* hooks — additive; every scope's hooks are kept (a project must
|
|
201
|
+
not be able to silently drop your personal hooks).
|
|
202
|
+
"""
|
|
203
|
+
user_base = (
|
|
204
|
+
user_dir if user_dir is not None else (Path.home() / USER_DIRNAME)
|
|
205
|
+
)
|
|
206
|
+
project_base = project_root / PROJECT_DIRNAME
|
|
207
|
+
|
|
208
|
+
ext = Extensions()
|
|
209
|
+
ext.skill_paths = _discover_skill_dirs(user_base) + _discover_skill_dirs(
|
|
210
|
+
project_base
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
merged: dict[str, AgentSpec] = {}
|
|
214
|
+
for spec in _discover_agents(user_base, "user"):
|
|
215
|
+
merged[spec.name] = spec
|
|
216
|
+
for spec in _discover_agents(project_base, "project"):
|
|
217
|
+
merged[spec.name] = spec # project wins
|
|
218
|
+
ext.agent_specs = list(merged.values())
|
|
219
|
+
|
|
220
|
+
ext.hook_specs = _discover_hooks(user_base, "user") + _discover_hooks(
|
|
221
|
+
project_base, "project"
|
|
222
|
+
)
|
|
223
|
+
# MCP servers — additive across scopes like hooks. A project's
|
|
224
|
+
# [[mcp]] servers ride the same trust gate (see loom_code.trust).
|
|
225
|
+
ext.mcp_specs = _discover_mcp(user_base, "user") + _discover_mcp(
|
|
226
|
+
project_base, "project"
|
|
227
|
+
)
|
|
228
|
+
return ext
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---- skills ---------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _discover_skill_dirs(base: Path) -> list[Path]:
|
|
235
|
+
"""Return each ``<base>/skills/<name>/`` dir that has a SKILL.md.
|
|
236
|
+
|
|
237
|
+
Same shape as ``agent._bundled_skill_paths`` so the result drops
|
|
238
|
+
straight into the ``skills=`` list every agent builder already
|
|
239
|
+
accepts."""
|
|
240
|
+
skills_dir = base / "skills"
|
|
241
|
+
if not skills_dir.is_dir():
|
|
242
|
+
return []
|
|
243
|
+
out: list[Path] = []
|
|
244
|
+
for entry in sorted(skills_dir.iterdir()):
|
|
245
|
+
if entry.is_dir() and (entry / "SKILL.md").is_file():
|
|
246
|
+
out.append(entry)
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---- agents ---------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _discover_agents(base: Path, source: str) -> list[AgentSpec]:
|
|
254
|
+
"""Parse every ``<base>/agents/*.md`` into an :class:`AgentSpec`.
|
|
255
|
+
|
|
256
|
+
A file missing either ``name`` or ``description`` frontmatter is
|
|
257
|
+
skipped — those two fields are the delegation contract (the
|
|
258
|
+
supervisor can't route to an agent it can't describe). Unreadable
|
|
259
|
+
or malformed files are skipped rather than aborting discovery, so
|
|
260
|
+
one bad file doesn't break the whole session."""
|
|
261
|
+
agents_dir = base / "agents"
|
|
262
|
+
if not agents_dir.is_dir():
|
|
263
|
+
return []
|
|
264
|
+
out: list[AgentSpec] = []
|
|
265
|
+
for path in sorted(agents_dir.glob("*.md")):
|
|
266
|
+
try:
|
|
267
|
+
text = path.read_text(encoding="utf-8")
|
|
268
|
+
except OSError:
|
|
269
|
+
continue
|
|
270
|
+
fm, body = _parse_frontmatter(text)
|
|
271
|
+
name = str(fm.get("name") or path.stem).strip()
|
|
272
|
+
description = str(fm.get("description") or "").strip()
|
|
273
|
+
if not name or not description:
|
|
274
|
+
continue
|
|
275
|
+
model_raw = fm.get("model")
|
|
276
|
+
model = str(model_raw).strip() if model_raw else None
|
|
277
|
+
tools = _normalize_tools(fm.get("tools", ()))
|
|
278
|
+
out.append(
|
|
279
|
+
AgentSpec(
|
|
280
|
+
name=name,
|
|
281
|
+
description=description,
|
|
282
|
+
system_prompt=body,
|
|
283
|
+
model=model,
|
|
284
|
+
tools=tools,
|
|
285
|
+
source=source,
|
|
286
|
+
path=path,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _normalize_tools(value: object) -> tuple[str, ...]:
|
|
293
|
+
"""Coerce a ``tools`` frontmatter value into a tuple of names.
|
|
294
|
+
|
|
295
|
+
Accepts a YAML-ish list (already split by :func:`_parse_frontmatter`)
|
|
296
|
+
or a single string like ``"read, edit, bash"`` / ``"read edit"``."""
|
|
297
|
+
if isinstance(value, (list, tuple)):
|
|
298
|
+
return tuple(str(v).strip() for v in value if str(v).strip())
|
|
299
|
+
if isinstance(value, str):
|
|
300
|
+
parts = re.split(r"[,\s]+", value.strip())
|
|
301
|
+
return tuple(p for p in parts if p)
|
|
302
|
+
return ()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---- hooks ----------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _discover_hooks(base: Path, source: str) -> list[HookSpec]:
|
|
309
|
+
"""Parse ``[[hooks]]`` entries from ``<base>/settings.toml``.
|
|
310
|
+
|
|
311
|
+
Expected shape::
|
|
312
|
+
|
|
313
|
+
[[hooks]]
|
|
314
|
+
event = "PreToolUse"
|
|
315
|
+
matcher = "bash" # optional, defaults to "*"
|
|
316
|
+
command = "./scripts/check.sh"
|
|
317
|
+
timeout = 30 # optional, seconds
|
|
318
|
+
|
|
319
|
+
Entries with an unknown ``event`` or a missing ``event``/``command``
|
|
320
|
+
are skipped. A malformed TOML file is skipped wholesale (returns
|
|
321
|
+
``[]``) rather than aborting the session."""
|
|
322
|
+
settings = base / "settings.toml"
|
|
323
|
+
if not settings.is_file():
|
|
324
|
+
return []
|
|
325
|
+
try:
|
|
326
|
+
data = tomllib.loads(settings.read_text(encoding="utf-8"))
|
|
327
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
328
|
+
return []
|
|
329
|
+
raw = data.get("hooks")
|
|
330
|
+
if not isinstance(raw, list):
|
|
331
|
+
return []
|
|
332
|
+
out: list[HookSpec] = []
|
|
333
|
+
for entry in raw:
|
|
334
|
+
if not isinstance(entry, dict):
|
|
335
|
+
continue
|
|
336
|
+
event = str(entry.get("event", "")).strip()
|
|
337
|
+
command = str(entry.get("command", "")).strip()
|
|
338
|
+
if event not in KNOWN_HOOK_EVENTS or not command:
|
|
339
|
+
continue
|
|
340
|
+
matcher = str(entry.get("matcher", "*")).strip() or "*"
|
|
341
|
+
try:
|
|
342
|
+
timeout = float(entry.get("timeout", 60.0))
|
|
343
|
+
except (TypeError, ValueError):
|
|
344
|
+
timeout = 60.0
|
|
345
|
+
out.append(
|
|
346
|
+
HookSpec(
|
|
347
|
+
event=event,
|
|
348
|
+
command=command,
|
|
349
|
+
matcher=matcher,
|
|
350
|
+
timeout=timeout,
|
|
351
|
+
source=source,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _discover_mcp(base: Path, source: str) -> list[McpEntry]:
|
|
358
|
+
"""Parse ``[[mcp]]`` blocks from ``<base>/settings.toml`` into
|
|
359
|
+
:class:`McpEntry` (source-tagged :class:`MCPServerSpec`).
|
|
360
|
+
|
|
361
|
+
Each block declares one MCP server. Recognised keys::
|
|
362
|
+
|
|
363
|
+
[[mcp]]
|
|
364
|
+
name = "linear" # required, unique
|
|
365
|
+
transport = "stdio" # "stdio" (default) or "http"
|
|
366
|
+
command = "npx" # stdio: the server binary
|
|
367
|
+
args = ["-y", "linear-mcp"]
|
|
368
|
+
env = { LINEAR_API_KEY = "..." }
|
|
369
|
+
# or, for http:
|
|
370
|
+
# transport = "http"
|
|
371
|
+
# url = "https://mcp.example.com"
|
|
372
|
+
# headers = { Authorization = "Bearer ..." }
|
|
373
|
+
|
|
374
|
+
Bad entries are skipped (missing name, or stdio without a command /
|
|
375
|
+
http without a url) rather than aborting the session — matches the
|
|
376
|
+
lenient, never-crash posture of :func:`_discover_hooks`. Returns
|
|
377
|
+
``[]`` when the ``mcp`` extra isn't installed (the lazy import
|
|
378
|
+
fails) so loom-code runs fine without it.
|
|
379
|
+
"""
|
|
380
|
+
settings = base / "settings.toml"
|
|
381
|
+
if not settings.is_file():
|
|
382
|
+
return []
|
|
383
|
+
try:
|
|
384
|
+
data = tomllib.loads(settings.read_text(encoding="utf-8"))
|
|
385
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
386
|
+
return []
|
|
387
|
+
raw = data.get("mcp")
|
|
388
|
+
if not isinstance(raw, list):
|
|
389
|
+
return []
|
|
390
|
+
try:
|
|
391
|
+
from loomflow.mcp import MCPServerSpec
|
|
392
|
+
except ImportError:
|
|
393
|
+
# ``mcp`` extra not installed — silently skip MCP discovery.
|
|
394
|
+
return []
|
|
395
|
+
out: list[McpEntry] = []
|
|
396
|
+
for entry in raw:
|
|
397
|
+
if not isinstance(entry, dict):
|
|
398
|
+
continue
|
|
399
|
+
name = str(entry.get("name", "")).strip()
|
|
400
|
+
if not name:
|
|
401
|
+
continue
|
|
402
|
+
transport = str(entry.get("transport", "stdio")).strip() or "stdio"
|
|
403
|
+
command = str(entry.get("command", "")).strip() or None
|
|
404
|
+
url = str(entry.get("url", "")).strip() or None
|
|
405
|
+
# Skip specs that can't possibly connect — same "bad entry is
|
|
406
|
+
# dropped, not fatal" rule as hooks.
|
|
407
|
+
if transport == "stdio" and not command:
|
|
408
|
+
continue
|
|
409
|
+
if transport == "http" and not url:
|
|
410
|
+
continue
|
|
411
|
+
# MCPServerSpec is a frozen dataclass with hashable (tuple)
|
|
412
|
+
# fields, so coerce the TOML list/dict into tuples here.
|
|
413
|
+
args_raw = entry.get("args", [])
|
|
414
|
+
args = (
|
|
415
|
+
tuple(str(a) for a in args_raw)
|
|
416
|
+
if isinstance(args_raw, list)
|
|
417
|
+
else ()
|
|
418
|
+
)
|
|
419
|
+
env_raw = entry.get("env", {})
|
|
420
|
+
env = (
|
|
421
|
+
tuple((str(k), str(v)) for k, v in env_raw.items())
|
|
422
|
+
if isinstance(env_raw, dict)
|
|
423
|
+
else ()
|
|
424
|
+
)
|
|
425
|
+
headers_raw = entry.get("headers", {})
|
|
426
|
+
headers = (
|
|
427
|
+
tuple((str(k), str(v)) for k, v in headers_raw.items())
|
|
428
|
+
if isinstance(headers_raw, dict)
|
|
429
|
+
else ()
|
|
430
|
+
)
|
|
431
|
+
description = str(entry.get("description", "")).strip()
|
|
432
|
+
spec = MCPServerSpec(
|
|
433
|
+
name=name,
|
|
434
|
+
transport=transport, # type: ignore[arg-type]
|
|
435
|
+
command=command,
|
|
436
|
+
args=args,
|
|
437
|
+
env=env,
|
|
438
|
+
url=url,
|
|
439
|
+
headers=headers,
|
|
440
|
+
description=description,
|
|
441
|
+
)
|
|
442
|
+
out.append(McpEntry(source=source, spec=spec))
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---- frontmatter ----------------------------------------------------
|
|
447
|
+
|
|
448
|
+
_FENCE_RE = re.compile(
|
|
449
|
+
r"\A---[ \t]*\n(.*?)\n---[ \t]*(?:\n(.*))?\Z", re.DOTALL
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
|
454
|
+
"""Split ``---\\n<frontmatter>\\n---\\n<body>`` into ``(fields, body)``.
|
|
455
|
+
|
|
456
|
+
A deliberately tiny parser — loom-code doesn't depend on pyyaml and
|
|
457
|
+
subagent frontmatter only needs ``key: value`` scalars plus a list
|
|
458
|
+
value for ``tools``. Supported list forms::
|
|
459
|
+
|
|
460
|
+
tools: [read, edit] # flow
|
|
461
|
+
tools: # block
|
|
462
|
+
- read
|
|
463
|
+
- edit
|
|
464
|
+
|
|
465
|
+
Scalars are returned as strings (surrounding quotes stripped);
|
|
466
|
+
flow/block lists are returned as ``list[str]``. Comma-splitting of
|
|
467
|
+
bare scalars is intentionally NOT done here — descriptions contain
|
|
468
|
+
commas — so ``tools: read, edit`` arrives as the string
|
|
469
|
+
``"read, edit"`` and :func:`_normalize_tools` splits it. Returns
|
|
470
|
+
``({}, text)`` when there's no frontmatter fence."""
|
|
471
|
+
m = _FENCE_RE.match(text)
|
|
472
|
+
if not m:
|
|
473
|
+
return {}, text.strip()
|
|
474
|
+
raw = m.group(1)
|
|
475
|
+
body = (m.group(2) or "").strip()
|
|
476
|
+
|
|
477
|
+
fields: dict[str, object] = {}
|
|
478
|
+
lines = raw.splitlines()
|
|
479
|
+
i = 0
|
|
480
|
+
while i < len(lines):
|
|
481
|
+
line = lines[i]
|
|
482
|
+
stripped = line.strip()
|
|
483
|
+
if not stripped or stripped.startswith("#") or ":" not in line:
|
|
484
|
+
i += 1
|
|
485
|
+
continue
|
|
486
|
+
key, _, val = line.partition(":")
|
|
487
|
+
key = key.strip()
|
|
488
|
+
val = val.strip()
|
|
489
|
+
if not val:
|
|
490
|
+
# Possible block list on the following indented "- " lines.
|
|
491
|
+
items: list[str] = []
|
|
492
|
+
j = i + 1
|
|
493
|
+
while j < len(lines) and lines[j].lstrip().startswith("- "):
|
|
494
|
+
items.append(_strip_quotes(lines[j].lstrip()[2:].strip()))
|
|
495
|
+
j += 1
|
|
496
|
+
if items:
|
|
497
|
+
fields[key] = items
|
|
498
|
+
i = j
|
|
499
|
+
continue
|
|
500
|
+
fields[key] = ""
|
|
501
|
+
i += 1
|
|
502
|
+
continue
|
|
503
|
+
fields[key] = _coerce_value(val)
|
|
504
|
+
i += 1
|
|
505
|
+
return fields, body
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _coerce_value(val: str) -> object:
|
|
509
|
+
"""Turn a frontmatter scalar into a flow list (``[a, b]``) or a
|
|
510
|
+
quote-stripped string."""
|
|
511
|
+
if val.startswith("[") and val.endswith("]"):
|
|
512
|
+
inner = val[1:-1]
|
|
513
|
+
return [
|
|
514
|
+
_strip_quotes(p.strip()) for p in inner.split(",") if p.strip()
|
|
515
|
+
]
|
|
516
|
+
return _strip_quotes(val)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _strip_quotes(val: str) -> str:
|
|
520
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in {'"', "'"}:
|
|
521
|
+
return val[1:-1]
|
|
522
|
+
return val
|