loki-mode 6.79.0 → 6.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ """Component registry for Magic Modules.
2
+
3
+ Tracks every generated component with: name, version (semver), spec hash,
4
+ file paths, tags, creation/update timestamps, debate results, deprecation.
5
+
6
+ Storage: JSON at .loki/magic/registry.json (atomic writes via tmp + rename).
7
+ Standard library only.
8
+ """
9
+
10
+ import json
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass, asdict, field
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
18
+ NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
19
+
20
+
21
+ def _utcnow() -> str:
22
+ """Return current UTC timestamp in ISO 8601 format."""
23
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
24
+
25
+
26
+ @dataclass
27
+ class ComponentEntry:
28
+ name: str
29
+ version: str = "1.0.0"
30
+ spec_path: str = ""
31
+ react_path: str = ""
32
+ webcomponent_path: str = ""
33
+ test_path: str = ""
34
+ spec_hash: str = ""
35
+ created_at: str = ""
36
+ updated_at: str = ""
37
+ tags: list = field(default_factory=list)
38
+ description: str = ""
39
+ targets: list = field(default_factory=lambda: ["react"])
40
+ debate_passed: bool = False
41
+ debate_result: dict = field(default_factory=dict)
42
+ deprecated: bool = False
43
+ replaces: str = ""
44
+
45
+ @classmethod
46
+ def from_dict(cls, data: dict) -> "ComponentEntry":
47
+ """Construct from a dict, ignoring unknown keys."""
48
+ known = {f for f in cls.__dataclass_fields__}
49
+ filtered = {k: v for k, v in data.items() if k in known}
50
+ return cls(**filtered)
51
+
52
+
53
+ class ComponentRegistry:
54
+ def __init__(self, project_dir: str = "."):
55
+ self.project_dir = Path(project_dir)
56
+ self.registry_path = self.project_dir / ".loki" / "magic" / "registry.json"
57
+ self.registry_path.parent.mkdir(parents=True, exist_ok=True)
58
+
59
+ # ------------------------------------------------------------------
60
+ # Persistence
61
+ # ------------------------------------------------------------------
62
+ def load(self) -> dict:
63
+ """Load registry from disk, return empty structure if missing."""
64
+ if not self.registry_path.exists():
65
+ return {"version": "1", "updated_at": "", "components": []}
66
+ try:
67
+ data = json.loads(self.registry_path.read_text())
68
+ except (json.JSONDecodeError, OSError):
69
+ # Back up corrupted registry and start fresh
70
+ try:
71
+ backup = self.registry_path.with_suffix(".json.corrupt")
72
+ self.registry_path.rename(backup)
73
+ except OSError:
74
+ pass
75
+ return {"version": "1", "updated_at": "", "components": []}
76
+
77
+ # Ensure required fields exist
78
+ if not isinstance(data, dict):
79
+ return {"version": "1", "updated_at": "", "components": []}
80
+ data.setdefault("version", "1")
81
+ data.setdefault("updated_at", "")
82
+ data.setdefault("components", [])
83
+ if not isinstance(data["components"], list):
84
+ data["components"] = []
85
+ return data
86
+
87
+ def save(self, data: dict) -> None:
88
+ """Atomic write via temp + rename."""
89
+ data["updated_at"] = _utcnow()
90
+ tmp = self.registry_path.with_suffix(".json.tmp")
91
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=False))
92
+ tmp.replace(self.registry_path)
93
+
94
+ # ------------------------------------------------------------------
95
+ # CRUD
96
+ # ------------------------------------------------------------------
97
+ def register(self, entry: ComponentEntry) -> ComponentEntry:
98
+ """Add or update a component entry.
99
+
100
+ If name exists, bump version (patch) when version matches existing.
101
+ Validates name format and semver.
102
+ """
103
+ if not NAME_RE.match(entry.name):
104
+ raise ValueError(f"Invalid component name: {entry.name}")
105
+ if not SEMVER_RE.match(entry.version):
106
+ raise ValueError(f"Invalid semver: {entry.version}")
107
+
108
+ now = _utcnow()
109
+ data = self.load()
110
+ existing_idx = next(
111
+ (i for i, c in enumerate(data["components"]) if c.get("name") == entry.name),
112
+ None,
113
+ )
114
+
115
+ if existing_idx is not None:
116
+ existing = data["components"][existing_idx]
117
+ entry.created_at = existing.get("created_at") or now
118
+ entry.updated_at = now
119
+ # Auto-bump patch if same version as existing
120
+ if entry.version == existing.get("version"):
121
+ entry.version = self._bump_patch(existing.get("version", "1.0.0"))
122
+ data["components"][existing_idx] = asdict(entry)
123
+ else:
124
+ entry.created_at = entry.created_at or now
125
+ entry.updated_at = now
126
+ data["components"].append(asdict(entry))
127
+
128
+ self.save(data)
129
+ return entry
130
+
131
+ def get(self, name: str) -> Optional[ComponentEntry]:
132
+ """Fetch component by name. Returns None if not found."""
133
+ data = self.load()
134
+ for c in data["components"]:
135
+ if c.get("name") == name:
136
+ return ComponentEntry.from_dict(c)
137
+ return None
138
+
139
+ def remove(self, name: str) -> bool:
140
+ """Remove component entry (does not delete files). Returns True if removed."""
141
+ data = self.load()
142
+ original_count = len(data["components"])
143
+ data["components"] = [c for c in data["components"] if c.get("name") != name]
144
+ if len(data["components"]) == original_count:
145
+ return False
146
+ self.save(data)
147
+ return True
148
+
149
+ def search(
150
+ self,
151
+ query: str = "",
152
+ tags: list = None,
153
+ target: str = None,
154
+ ) -> list:
155
+ """Search by name/description substring, tags, or target framework.
156
+
157
+ - query: case-insensitive substring against name and description.
158
+ - tags: all tags must be present on the component.
159
+ - target: match when target appears in component's targets list (or
160
+ component targets includes "both").
161
+ """
162
+ data = self.load()
163
+ results = []
164
+ q = (query or "").lower().strip()
165
+ tag_set = set(tags or [])
166
+
167
+ for c in data["components"]:
168
+ name = c.get("name", "")
169
+ description = c.get("description", "")
170
+ ctags = set(c.get("tags") or [])
171
+ ctargets = c.get("targets") or []
172
+
173
+ if q and q not in name.lower() and q not in description.lower():
174
+ continue
175
+ if tag_set and not tag_set.issubset(ctags):
176
+ continue
177
+ if target:
178
+ if target not in ctargets and "both" not in ctargets:
179
+ continue
180
+ results.append(ComponentEntry.from_dict(c))
181
+ return results
182
+
183
+ def list_all(self, include_deprecated: bool = False) -> list:
184
+ """List all components. Excludes deprecated by default."""
185
+ data = self.load()
186
+ results = []
187
+ for c in data["components"]:
188
+ if not include_deprecated and c.get("deprecated"):
189
+ continue
190
+ results.append(ComponentEntry.from_dict(c))
191
+ return results
192
+
193
+ def deprecate(self, name: str, replaces: str = "") -> bool:
194
+ """Mark component as deprecated. `replaces` points to newer component."""
195
+ data = self.load()
196
+ for c in data["components"]:
197
+ if c.get("name") == name:
198
+ c["deprecated"] = True
199
+ if replaces:
200
+ c["replaces"] = replaces
201
+ c["updated_at"] = _utcnow()
202
+ self.save(data)
203
+ return True
204
+ return False
205
+
206
+ # ------------------------------------------------------------------
207
+ # Stats & maintenance
208
+ # ------------------------------------------------------------------
209
+ def stats(self) -> dict:
210
+ """Return registry stats: count, per-target counts, avg debate score, etc."""
211
+ data = self.load()
212
+ components = data["components"]
213
+ total = len(components)
214
+ deprecated = sum(1 for c in components if c.get("deprecated"))
215
+ active = total - deprecated
216
+
217
+ target_counts = {"react": 0, "webcomponent": 0, "both": 0}
218
+ for c in components:
219
+ for t in c.get("targets") or []:
220
+ if t in target_counts:
221
+ target_counts[t] += 1
222
+
223
+ debate_passed = sum(1 for c in components if c.get("debate_passed"))
224
+ scores = []
225
+ for c in components:
226
+ res = c.get("debate_result") or {}
227
+ score = res.get("score")
228
+ if isinstance(score, (int, float)):
229
+ scores.append(float(score))
230
+ avg_debate_score = sum(scores) / len(scores) if scores else 0.0
231
+
232
+ tag_counts: dict = {}
233
+ for c in components:
234
+ for t in c.get("tags") or []:
235
+ tag_counts[t] = tag_counts.get(t, 0) + 1
236
+
237
+ return {
238
+ "total": total,
239
+ "active": active,
240
+ "deprecated": deprecated,
241
+ "targets": target_counts,
242
+ "debate_passed": debate_passed,
243
+ "avg_debate_score": avg_debate_score,
244
+ "tags": tag_counts,
245
+ "updated_at": data.get("updated_at", ""),
246
+ }
247
+
248
+ def prune(self, days_unused: int = 90) -> int:
249
+ """Remove deprecated entries whose updated_at is older than N days.
250
+
251
+ Returns the number of entries removed.
252
+ """
253
+ if days_unused < 0:
254
+ return 0
255
+ data = self.load()
256
+ cutoff = time.time() - (days_unused * 86400)
257
+ kept = []
258
+ removed = 0
259
+ for c in data["components"]:
260
+ if c.get("deprecated"):
261
+ ts = self._parse_ts(c.get("updated_at", ""))
262
+ if ts is not None and ts < cutoff:
263
+ removed += 1
264
+ continue
265
+ kept.append(c)
266
+ if removed:
267
+ data["components"] = kept
268
+ self.save(data)
269
+ return removed
270
+
271
+ # ------------------------------------------------------------------
272
+ # Helpers
273
+ # ------------------------------------------------------------------
274
+ @staticmethod
275
+ def _bump_patch(version: str) -> str:
276
+ """1.2.3 -> 1.2.4"""
277
+ m = SEMVER_RE.match(version or "")
278
+ if not m:
279
+ return "1.0.0"
280
+ major, minor, patch = m.groups()
281
+ return f"{major}.{minor}.{int(patch) + 1}"
282
+
283
+ @staticmethod
284
+ def _parse_ts(value: str) -> Optional[float]:
285
+ """Parse an ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ) to epoch seconds."""
286
+ if not value:
287
+ return None
288
+ try:
289
+ t = time.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
290
+ return time.mktime(t) - time.timezone
291
+ except (ValueError, TypeError):
292
+ return None
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Module-level convenience API (called by autonomy/loki cmd_magic)
297
+ # ---------------------------------------------------------------------------
298
+
299
+ def register_component(
300
+ registry_path: str = ".loki/magic/registry.json",
301
+ name: str = "",
302
+ spec_path: str = "",
303
+ target: str = "react",
304
+ react_path: str = "",
305
+ webcomponent_path: str = "",
306
+ test_path: str = "",
307
+ spec_hash: str = "",
308
+ description: str = "",
309
+ tags=None,
310
+ placement=None,
311
+ **extra,
312
+ ) -> dict:
313
+ """Register or update a component entry. Returns the stored entry as dict."""
314
+ tags = list(tags) if tags else []
315
+ if placement:
316
+ tags.append(f"placement:{placement}")
317
+ from pathlib import Path as _P
318
+ project_dir = str(_P(registry_path).parent.parent.parent)
319
+ reg = ComponentRegistry(project_dir)
320
+ entry = ComponentEntry(
321
+ name=name,
322
+ spec_path=spec_path,
323
+ react_path=react_path,
324
+ webcomponent_path=webcomponent_path,
325
+ test_path=test_path,
326
+ spec_hash=spec_hash,
327
+ description=description,
328
+ tags=tags,
329
+ targets=[target] if target != "both" else ["react", "webcomponent"],
330
+ )
331
+ stored = reg.register(entry)
332
+ from dataclasses import asdict as _asdict
333
+ return _asdict(stored)
334
+
335
+
336
+ def prune_registry(registry_path: str = ".loki/magic/registry.json", days_unused: int = 90) -> int:
337
+ """Remove deprecated entries older than N days. Returns count removed."""
338
+ from pathlib import Path as _P
339
+ project_dir = str(_P(registry_path).parent.parent.parent)
340
+ return ComponentRegistry(project_dir).prune(days_unused)
@@ -0,0 +1,337 @@
1
+ """Spec management for Magic Modules.
2
+
3
+ Specs are markdown files describing desired component behavior. They are
4
+ the source of truth -- implementations regenerate when specs change.
5
+ """
6
+
7
+ import re
8
+ from pathlib import Path
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+
13
+ SPEC_TEMPLATE = """# {name}
14
+
15
+ ## Description
16
+ {description}
17
+
18
+ ## Props
19
+ - `prop1` (string, required): Description
20
+ - `prop2` (boolean, optional, default=false): Description
21
+
22
+ ## Behavior
23
+ Describe how the component behaves, state transitions, user interactions.
24
+
25
+ ## Visual / Styling
26
+ Describe the visual design: colors, spacing, typography, responsive behavior.
27
+
28
+ ## Accessibility
29
+ - Keyboard navigation: [describe]
30
+ - Screen reader: [describe]
31
+ - Focus management: [describe]
32
+
33
+ ## Examples
34
+ ```tsx
35
+ <{name} prop1="value" />
36
+ ```
37
+ """
38
+
39
+
40
+ @dataclass
41
+ class ComponentSpec:
42
+ """Structured representation of a component spec."""
43
+
44
+ name: str
45
+ description: str
46
+ markdown: str
47
+ props: dict = field(default_factory=dict)
48
+ behavior: str = ""
49
+ visual: str = ""
50
+ a11y: list = field(default_factory=list)
51
+ examples: list = field(default_factory=list)
52
+
53
+
54
+ class SpecManager:
55
+ """Manage component specs stored as markdown under .loki/magic/specs/."""
56
+
57
+ # Matches a bullet prop line like:
58
+ # - `name` (type, required): description
59
+ # - `name` (type, optional, default=false): description
60
+ _PROP_LINE = re.compile(
61
+ r"^\s*[-*]\s*`(?P<name>[^`]+)`\s*"
62
+ r"(?:\((?P<meta>[^)]*)\))?"
63
+ r"\s*:\s*(?P<desc>.*)$"
64
+ )
65
+
66
+ # Code fences used to extract examples.
67
+ _CODE_FENCE = re.compile(
68
+ r"```(?P<lang>[a-zA-Z0-9_+-]*)\n(?P<code>.*?)```",
69
+ re.DOTALL,
70
+ )
71
+
72
+ def __init__(self, project_dir: str = "."):
73
+ self.specs_dir = Path(project_dir) / ".loki" / "magic" / "specs"
74
+ self.specs_dir.mkdir(parents=True, exist_ok=True)
75
+
76
+ # ------------------------------------------------------------------
77
+ # Public API
78
+ # ------------------------------------------------------------------
79
+
80
+ def create_spec(self, name: str, description: str) -> ComponentSpec:
81
+ """Create a new spec file from a description.
82
+
83
+ This creates a scaffolded spec using SPEC_TEMPLATE. A caller can
84
+ later enrich the markdown via AI assistance and re-save.
85
+ """
86
+ safe_name = self._sanitize_name(name)
87
+ markdown = SPEC_TEMPLATE.format(name=safe_name, description=description)
88
+ spec = self.parse_markdown(markdown)
89
+ # parse_markdown may extract a slightly different name; honor the
90
+ # requested one for filesystem purposes.
91
+ spec.name = safe_name
92
+ self.save_spec(spec)
93
+ return spec
94
+
95
+ def load_spec(self, name: str) -> Optional[ComponentSpec]:
96
+ """Load existing spec by name."""
97
+ path = self._spec_path(name)
98
+ if not path.exists():
99
+ return None
100
+ markdown = path.read_text(encoding="utf-8")
101
+ spec = self.parse_markdown(markdown)
102
+ # Filesystem name is authoritative.
103
+ spec.name = self._sanitize_name(name)
104
+ return spec
105
+
106
+ def save_spec(self, spec: ComponentSpec) -> Path:
107
+ """Write spec to disk as .loki/magic/specs/<name>.md."""
108
+ path = self._spec_path(spec.name)
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ path.write_text(spec.markdown, encoding="utf-8")
111
+ return path
112
+
113
+ def parse_markdown(self, md_text: str) -> ComponentSpec:
114
+ """Parse markdown into a structured ComponentSpec.
115
+
116
+ Recognized sections (case-insensitive header match):
117
+ # <Component Name>
118
+ ## Description
119
+ ## Props
120
+ ## Behavior
121
+ ## Visual / Styling (also: Visual, Styling)
122
+ ## Accessibility (also: A11y)
123
+ ## Examples
124
+ """
125
+ sections = self._split_sections(md_text)
126
+
127
+ name = sections.get("__title__", "").strip() or "Component"
128
+ description = sections.get("description", "").strip()
129
+ behavior = sections.get("behavior", "").strip()
130
+ visual = (
131
+ sections.get("visual / styling")
132
+ or sections.get("visual")
133
+ or sections.get("styling")
134
+ or ""
135
+ ).strip()
136
+
137
+ props_text = sections.get("props", "")
138
+ props = self._parse_props(props_text)
139
+
140
+ a11y_text = (
141
+ sections.get("accessibility")
142
+ or sections.get("a11y")
143
+ or ""
144
+ )
145
+ a11y = self._parse_bullets(a11y_text)
146
+
147
+ examples_text = sections.get("examples", "")
148
+ examples = self._parse_examples(examples_text)
149
+
150
+ return ComponentSpec(
151
+ name=name,
152
+ description=description,
153
+ markdown=md_text,
154
+ props=props,
155
+ behavior=behavior,
156
+ visual=visual,
157
+ a11y=a11y,
158
+ examples=examples,
159
+ )
160
+
161
+ def list_specs(self) -> list:
162
+ """List all spec names (without the .md extension)."""
163
+ if not self.specs_dir.exists():
164
+ return []
165
+ return sorted(p.stem for p in self.specs_dir.glob("*.md"))
166
+
167
+ def delete_spec(self, name: str) -> bool:
168
+ """Delete spec file. Returns True if removed, False if not found."""
169
+ path = self._spec_path(name)
170
+ if not path.exists():
171
+ return False
172
+ path.unlink()
173
+ return True
174
+
175
+ # ------------------------------------------------------------------
176
+ # Internal helpers
177
+ # ------------------------------------------------------------------
178
+
179
+ def _spec_path(self, name: str) -> Path:
180
+ return self.specs_dir / f"{self._sanitize_name(name)}.md"
181
+
182
+ @staticmethod
183
+ def _sanitize_name(name: str) -> str:
184
+ """Strip whitespace and any path separators from a spec name."""
185
+ cleaned = name.strip()
186
+ # Disallow path traversal characters.
187
+ cleaned = cleaned.replace("/", "_").replace("\\", "_")
188
+ return cleaned
189
+
190
+ @staticmethod
191
+ def _split_sections(md_text: str) -> dict:
192
+ """Split markdown into sections keyed by lowercased heading text.
193
+
194
+ The first H1 is stored under the special key '__title__'.
195
+ """
196
+ sections: dict = {}
197
+ current_key: Optional[str] = None
198
+ buffer: list = []
199
+ title_captured = False
200
+
201
+ def flush():
202
+ if current_key is not None:
203
+ sections[current_key] = "\n".join(buffer).strip("\n")
204
+
205
+ for line in md_text.splitlines():
206
+ h1 = re.match(r"^#\s+(.*?)\s*$", line)
207
+ h2 = re.match(r"^##\s+(.*?)\s*$", line)
208
+ if h1 and not title_captured:
209
+ sections["__title__"] = h1.group(1).strip()
210
+ title_captured = True
211
+ # Reset buffer so title body does not leak into next section.
212
+ if current_key is not None:
213
+ flush()
214
+ buffer = []
215
+ current_key = None
216
+ continue
217
+ if h2:
218
+ # Close previous section.
219
+ flush()
220
+ current_key = h2.group(1).strip().lower()
221
+ buffer = []
222
+ continue
223
+ buffer.append(line)
224
+
225
+ flush()
226
+ return sections
227
+
228
+ @classmethod
229
+ def _parse_props(cls, props_text: str) -> dict:
230
+ """Parse a bullet list of prop definitions into a dict.
231
+
232
+ Each entry looks like:
233
+ - `name` (type, required): description
234
+ - `name` (type, optional, default=false): description
235
+ """
236
+ props: dict = {}
237
+ for raw in props_text.splitlines():
238
+ line = raw.rstrip()
239
+ if not line.strip():
240
+ continue
241
+ match = cls._PROP_LINE.match(line)
242
+ if not match:
243
+ continue
244
+ name = match.group("name").strip()
245
+ meta_raw = (match.group("meta") or "").strip()
246
+ desc = match.group("desc").strip()
247
+
248
+ prop_type = ""
249
+ required = False
250
+ default = None
251
+ extras: list = []
252
+
253
+ if meta_raw:
254
+ parts = [p.strip() for p in meta_raw.split(",") if p.strip()]
255
+ for idx, part in enumerate(parts):
256
+ lowered = part.lower()
257
+ if idx == 0 and "=" not in part and lowered not in (
258
+ "required",
259
+ "optional",
260
+ ):
261
+ prop_type = part
262
+ continue
263
+ if lowered == "required":
264
+ required = True
265
+ elif lowered == "optional":
266
+ required = False
267
+ elif "=" in part:
268
+ key, _, value = part.partition("=")
269
+ key = key.strip().lower()
270
+ value = value.strip()
271
+ if key == "default":
272
+ default = value
273
+ else:
274
+ extras.append(part)
275
+ else:
276
+ extras.append(part)
277
+
278
+ props[name] = {
279
+ "type": prop_type,
280
+ "required": required,
281
+ "default": default,
282
+ "description": desc,
283
+ "extras": extras,
284
+ }
285
+ return props
286
+
287
+ @staticmethod
288
+ def _parse_bullets(text: str) -> list:
289
+ """Extract bullet list items as plain strings."""
290
+ items: list = []
291
+ for raw in text.splitlines():
292
+ line = raw.strip()
293
+ if not line:
294
+ continue
295
+ if line.startswith(("-", "*")):
296
+ items.append(line[1:].strip())
297
+ return items
298
+
299
+ @classmethod
300
+ def _parse_examples(cls, text: str) -> list:
301
+ """Extract fenced code blocks as example dicts."""
302
+ examples: list = []
303
+ for match in cls._CODE_FENCE.finditer(text):
304
+ lang = (match.group("lang") or "").strip()
305
+ code = match.group("code")
306
+ examples.append({"language": lang, "code": code})
307
+ return examples
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Module-level convenience API (called by autonomy/loki cmd_magic)
312
+ # ---------------------------------------------------------------------------
313
+
314
+ def generate_spec(name: str, out_path: str, from_spec=None, from_screenshot=None, description: str = "") -> str:
315
+ """Generate or copy a component spec to out_path.
316
+
317
+ If from_spec is given, copy its contents. If from_screenshot, delegate to
318
+ the generator's vision path. Otherwise create a template from SPEC_TEMPLATE.
319
+ Returns the path written.
320
+ """
321
+ from pathlib import Path as _P
322
+ out = _P(out_path)
323
+ out.parent.mkdir(parents=True, exist_ok=True)
324
+ if from_spec:
325
+ out.write_text(_P(from_spec).read_text())
326
+ return str(out)
327
+ if from_screenshot:
328
+ try:
329
+ from magic.core.generator import ComponentGenerator
330
+ md = ComponentGenerator().generate_from_screenshot(from_screenshot, name)
331
+ out.write_text(md)
332
+ return str(out)
333
+ except Exception:
334
+ pass
335
+ desc = description or f"Loki Magic component {name}."
336
+ out.write_text(SPEC_TEMPLATE.format(name=name, description=desc))
337
+ return str(out)