loki-mode 7.16.1 → 7.17.1

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.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-to-product system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product via the RARV-C closure loop, with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.16.1
6
+ # Loki Mode v7.17.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -383,4 +383,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
383
383
 
384
384
  ---
385
385
 
386
- **v7.16.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
386
+ **v7.17.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.16.1
1
+ 7.17.1
@@ -0,0 +1,652 @@
1
+ """
2
+ Loki Mode hub-install: install agents and PRD templates from a source.
3
+
4
+ R10 (agent + template marketplace) install MECHANISM. This module is the
5
+ single source of truth for manifest validation and installation. It is
6
+ called from the bash CLI (autonomy/loki, cmd_agent / cmd_template) via
7
+ heredoc and is directly importable for tests.
8
+
9
+ HONEST SCOPE
10
+ There is no hosted central marketplace server. A "source" is one of:
11
+ - a local path to a manifest file or a directory containing one
12
+ - a git repository URL (cloned to a temp dir, manifest read, then
13
+ the temp tree is discarded)
14
+ - an http(s) URL to a raw manifest file
15
+ A real hosted hub registry is future work. Everything here is
16
+ install-from-source.
17
+
18
+ SECURITY MODEL
19
+ Installing a community manifest must NEVER execute arbitrary code.
20
+ This module only ever reads JSON / markdown DATA. It does not eval,
21
+ import, or run anything from the fetched source. For git/url sources
22
+ it copies validated DATA only -- it never runs build hooks, npm
23
+ install, make, or any script that may be present in the tree.
24
+
25
+ Validation rejects, before any write:
26
+ - path traversal in the agent type / template name (.. / absolute /
27
+ path separators / null bytes)
28
+ - shadowing of a built-in agent type or built-in template name
29
+ - wrong field types, oversized fields, unexpected executable-looking
30
+ fields (postinstall / scripts / exec / command / hooks are
31
+ ignored, never run, and their presence is reported)
32
+ - manifests that are not the declared kind
33
+
34
+ STORE LAYOUT (project-local, under .loki/)
35
+ .loki/agents/installed.json list of installed agent manifests
36
+ .loki/templates/<name>.md installed template body
37
+ .loki/templates/installed.json index of installed templates
38
+
39
+ Project-local keeps installs scoped to the project and never writes
40
+ into the read-only package agents/ or templates/ dirs (those are
41
+ wiped on npm/Docker upgrade). A user-global ~/.loki store is a
42
+ natural future extension; not implemented here.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import os
49
+ import re
50
+ import shutil
51
+ import subprocess
52
+ import tempfile
53
+ from pathlib import Path
54
+ from typing import Any, Dict, List, Optional, Tuple
55
+ from urllib.parse import urlparse
56
+
57
+ SCHEMA_VERSION = 1
58
+
59
+ # Manifest field caps (defensive; reject obviously abusive payloads).
60
+ _MAX_PERSONA_LEN = 20000
61
+ _MAX_NAME_LEN = 80
62
+ _MAX_FOCUS_ITEMS = 64
63
+ _MAX_FOCUS_ITEM_LEN = 200
64
+ _MAX_TEMPLATE_BODY_LEN = 500000
65
+ _MAX_MANIFEST_BYTES = 2_000_000
66
+
67
+ # Fields that would imply code execution. We never run them; presence is
68
+ # reported so the operator knows they were ignored.
69
+ _EXECUTABLE_FIELDS = (
70
+ "postinstall",
71
+ "preinstall",
72
+ "scripts",
73
+ "exec",
74
+ "command",
75
+ "cmd",
76
+ "hooks",
77
+ "run",
78
+ "shell",
79
+ "eval",
80
+ )
81
+
82
+ # A safe identifier: lowercase letters, digits, hyphen. No path chars.
83
+ _SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$")
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Errors
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ class ManifestError(ValueError):
92
+ """Raised when a manifest is malformed, unsafe, or rejected."""
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Paths
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _target_dir() -> Path:
101
+ base = os.environ.get("LOKI_TARGET_DIR") or os.getcwd()
102
+ return Path(base)
103
+
104
+
105
+ def _loki_dir() -> Path:
106
+ rel = os.environ.get("LOKI_DIR", ".loki")
107
+ p = Path(rel)
108
+ if not p.is_absolute():
109
+ p = _target_dir() / rel
110
+ return p
111
+
112
+
113
+ def installed_agents_path() -> Path:
114
+ return _loki_dir() / "agents" / "installed.json"
115
+
116
+
117
+ def installed_templates_dir() -> Path:
118
+ return _loki_dir() / "templates"
119
+
120
+
121
+ def installed_templates_index() -> Path:
122
+ return installed_templates_dir() / "installed.json"
123
+
124
+
125
+ def _builtin_types_path() -> Path:
126
+ here = Path(__file__).resolve().parent
127
+ return here / "types.json"
128
+
129
+
130
+ def _builtin_templates_dir() -> Path:
131
+ here = Path(__file__).resolve().parent
132
+ return here.parent / "templates"
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Built-in inventory (for shadow checks)
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def builtin_agent_types() -> List[str]:
141
+ p = _builtin_types_path()
142
+ try:
143
+ with open(p, "r", encoding="utf-8") as f:
144
+ data = json.load(f)
145
+ except (OSError, json.JSONDecodeError):
146
+ return []
147
+ if not isinstance(data, list):
148
+ return []
149
+ out: List[str] = []
150
+ for d in data:
151
+ if isinstance(d, dict) and isinstance(d.get("type"), str):
152
+ out.append(d["type"])
153
+ return out
154
+
155
+
156
+ def builtin_template_names() -> List[str]:
157
+ d = _builtin_templates_dir()
158
+ if not d.is_dir():
159
+ return []
160
+ names: List[str] = []
161
+ for entry in sorted(d.iterdir()):
162
+ if entry.suffix == ".md" and entry.name.lower() != "readme.md":
163
+ names.append(entry.stem)
164
+ return names
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Safe id check
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def _assert_safe_id(value: Any, label: str) -> str:
173
+ if not isinstance(value, str) or not value:
174
+ raise ManifestError(f"{label} must be a non-empty string")
175
+ if "\x00" in value:
176
+ raise ManifestError(f"{label} contains a null byte")
177
+ if "/" in value or "\\" in value or ".." in value or os.path.isabs(value):
178
+ raise ManifestError(f"{label} must not contain path separators or '..': {value!r}")
179
+ if not _SAFE_ID_RE.match(value):
180
+ raise ManifestError(
181
+ f"{label} must match ^[a-z0-9][a-z0-9-]{{0,79}}$ (got {value!r})"
182
+ )
183
+ return value
184
+
185
+
186
+ def _report_executable_fields(manifest: Dict[str, Any]) -> List[str]:
187
+ """Return names of executable-looking fields. They are never run."""
188
+ return [k for k in manifest.keys() if k.lower() in _EXECUTABLE_FIELDS]
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Manifest validation: agent
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def validate_agent_manifest(manifest: Any) -> Dict[str, Any]:
197
+ """
198
+ Validate a community AGENT manifest. Returns a sanitized dict holding
199
+ only the known data fields. Raises ManifestError on anything unsafe.
200
+
201
+ Expected shape (JSON):
202
+ {
203
+ "schema_version": 1,
204
+ "kind": "agent",
205
+ "type": "community-rust-pro", # safe id, not a built-in
206
+ "name": "Rust Pro",
207
+ "swarm": "engineering",
208
+ "persona": "...",
209
+ "focus": ["rust", "tokio"], # optional list of strings
210
+ "capabilities": "..." # optional string
211
+ }
212
+ """
213
+ if not isinstance(manifest, dict):
214
+ raise ManifestError("agent manifest must be a JSON object")
215
+
216
+ kind = manifest.get("kind")
217
+ if kind not in (None, "agent"):
218
+ raise ManifestError(f"expected kind 'agent', got {kind!r}")
219
+
220
+ sv = manifest.get("schema_version", SCHEMA_VERSION)
221
+ if not isinstance(sv, int) or sv < 1 or sv > SCHEMA_VERSION:
222
+ raise ManifestError(f"unsupported schema_version: {sv!r}")
223
+
224
+ agent_type = _assert_safe_id(manifest.get("type"), "agent type")
225
+
226
+ if agent_type in builtin_agent_types():
227
+ raise ManifestError(
228
+ f"agent type {agent_type!r} shadows a built-in type; choose another"
229
+ )
230
+
231
+ name = manifest.get("name", agent_type)
232
+ if not isinstance(name, str) or not name or len(name) > _MAX_NAME_LEN:
233
+ raise ManifestError("agent name must be a non-empty string <= 80 chars")
234
+
235
+ swarm = manifest.get("swarm", "community")
236
+ if not isinstance(swarm, str) or not swarm or len(swarm) > _MAX_NAME_LEN:
237
+ raise ManifestError("agent swarm must be a non-empty string <= 80 chars")
238
+
239
+ persona = manifest.get("persona")
240
+ if not isinstance(persona, str) or not persona:
241
+ raise ManifestError("agent persona must be a non-empty string")
242
+ if len(persona) > _MAX_PERSONA_LEN:
243
+ raise ManifestError(f"agent persona exceeds {_MAX_PERSONA_LEN} chars")
244
+
245
+ focus = manifest.get("focus", [])
246
+ if focus is None:
247
+ focus = []
248
+ if not isinstance(focus, list):
249
+ raise ManifestError("agent focus must be a list of strings")
250
+ if len(focus) > _MAX_FOCUS_ITEMS:
251
+ raise ManifestError(f"agent focus has too many items (> {_MAX_FOCUS_ITEMS})")
252
+ clean_focus: List[str] = []
253
+ for item in focus:
254
+ if not isinstance(item, str) or len(item) > _MAX_FOCUS_ITEM_LEN:
255
+ raise ManifestError("agent focus items must be strings <= 200 chars")
256
+ clean_focus.append(item)
257
+
258
+ capabilities = manifest.get("capabilities", "")
259
+ if capabilities is None:
260
+ capabilities = ""
261
+ if not isinstance(capabilities, str) or len(capabilities) > _MAX_PERSONA_LEN:
262
+ raise ManifestError("agent capabilities must be a string")
263
+
264
+ sanitized = {
265
+ "type": agent_type,
266
+ "name": name,
267
+ "swarm": swarm,
268
+ "persona": persona,
269
+ "focus": clean_focus,
270
+ "capabilities": capabilities,
271
+ "source": "hub-installed",
272
+ }
273
+ return sanitized
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Manifest validation: template
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ def validate_template_manifest(manifest: Any) -> Tuple[Dict[str, Any], str]:
282
+ """
283
+ Validate a community TEMPLATE manifest. Returns (metadata, body) where
284
+ metadata holds the index entry and body is the PRD markdown to write.
285
+
286
+ Expected shape (JSON):
287
+ {
288
+ "schema_version": 1,
289
+ "kind": "template",
290
+ "name": "rust-cli", # safe id, not a built-in
291
+ "label": "Rust CLI Tool",
292
+ "description": "...",
293
+ "body": "# PRD ...markdown..." # the template content
294
+ }
295
+
296
+ Alternatively `body_file` may name a sibling markdown file relative to
297
+ the manifest directory (handled by the caller, not here).
298
+ """
299
+ if not isinstance(manifest, dict):
300
+ raise ManifestError("template manifest must be a JSON object")
301
+
302
+ kind = manifest.get("kind")
303
+ if kind not in (None, "template"):
304
+ raise ManifestError(f"expected kind 'template', got {kind!r}")
305
+
306
+ sv = manifest.get("schema_version", SCHEMA_VERSION)
307
+ if not isinstance(sv, int) or sv < 1 or sv > SCHEMA_VERSION:
308
+ raise ManifestError(f"unsupported schema_version: {sv!r}")
309
+
310
+ name = _assert_safe_id(manifest.get("name"), "template name")
311
+
312
+ if name in builtin_template_names():
313
+ raise ManifestError(
314
+ f"template name {name!r} shadows a built-in template; choose another"
315
+ )
316
+
317
+ label = manifest.get("label", name)
318
+ if not isinstance(label, str) or not label or len(label) > _MAX_NAME_LEN:
319
+ raise ManifestError("template label must be a non-empty string <= 80 chars")
320
+
321
+ description = manifest.get("description", "")
322
+ if description is None:
323
+ description = ""
324
+ if not isinstance(description, str) or len(description) > 500:
325
+ raise ManifestError("template description must be a string <= 500 chars")
326
+
327
+ body = manifest.get("body")
328
+ if not isinstance(body, str) or not body.strip():
329
+ raise ManifestError("template body must be a non-empty string")
330
+ if len(body) > _MAX_TEMPLATE_BODY_LEN:
331
+ raise ManifestError(f"template body exceeds {_MAX_TEMPLATE_BODY_LEN} chars")
332
+
333
+ metadata = {
334
+ "name": name,
335
+ "label": label,
336
+ "description": description,
337
+ "source": "hub-installed",
338
+ }
339
+ return metadata, body
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Source resolution (local path / git / url) -- DATA ONLY, never execute
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ def _read_manifest_file(path: Path) -> Any:
348
+ if path.is_dir():
349
+ candidate = path / "manifest.json"
350
+ if not candidate.exists():
351
+ raise ManifestError(f"no manifest.json in directory {path}")
352
+ path = candidate
353
+ if not path.exists():
354
+ raise ManifestError(f"source not found: {path}")
355
+ size = path.stat().st_size
356
+ if size > _MAX_MANIFEST_BYTES:
357
+ raise ManifestError(f"manifest too large ({size} bytes)")
358
+ try:
359
+ with open(path, "r", encoding="utf-8") as f:
360
+ return json.load(f)
361
+ except json.JSONDecodeError as e:
362
+ raise ManifestError(f"manifest is not valid JSON: {e}")
363
+ except OSError as e:
364
+ raise ManifestError(f"cannot read manifest: {e}")
365
+
366
+
367
+ def _looks_like_git(source: str) -> bool:
368
+ if source.endswith(".git"):
369
+ return True
370
+ if source.startswith(("git@", "git://", "ssh://")):
371
+ return True
372
+ if source.startswith(("http://", "https://")) and "github.com" in source:
373
+ # A github repo URL without an explicit file -> treat as git.
374
+ return not source.rstrip("/").endswith((".json", ".md"))
375
+ return False
376
+
377
+
378
+ def _looks_like_url(source: str) -> bool:
379
+ return source.startswith(("http://", "https://"))
380
+
381
+
382
+ def resolve_source(source: str) -> Tuple[Any, Optional[str]]:
383
+ """
384
+ Fetch the manifest from `source` and return (manifest_obj, base_dir).
385
+
386
+ base_dir is the directory the manifest lives in (so a body_file can be
387
+ resolved by the caller); it is a temp dir for git/url sources and is
388
+ the caller's responsibility to ignore for code -- only data is read.
389
+
390
+ Never executes anything from the source tree.
391
+ """
392
+ if not isinstance(source, str) or not source:
393
+ raise ManifestError("source must be a non-empty string")
394
+
395
+ # Local path takes priority if it exists on disk.
396
+ local = Path(os.path.expanduser(source))
397
+ if local.exists():
398
+ base = local if local.is_dir() else local.parent
399
+ return _read_manifest_file(local), str(base)
400
+
401
+ if _looks_like_git(source):
402
+ tmp = tempfile.mkdtemp(prefix="loki-hub-git-")
403
+ try:
404
+ # Shallow clone, no hooks, no submodule recursion. We only read
405
+ # files; we never run anything in the clone.
406
+ env = dict(os.environ)
407
+ env["GIT_TERMINAL_PROMPT"] = "0"
408
+ subprocess.run(
409
+ ["git", "clone", "--depth", "1", "--no-tags", source, tmp],
410
+ check=True,
411
+ capture_output=True,
412
+ timeout=120,
413
+ env=env,
414
+ )
415
+ except FileNotFoundError:
416
+ shutil.rmtree(tmp, ignore_errors=True)
417
+ raise ManifestError("git is not installed; cannot clone source")
418
+ except subprocess.CalledProcessError as e:
419
+ shutil.rmtree(tmp, ignore_errors=True)
420
+ detail = (e.stderr or b"").decode("utf-8", "replace")[:300]
421
+ raise ManifestError(f"git clone failed: {detail}")
422
+ except subprocess.TimeoutExpired:
423
+ shutil.rmtree(tmp, ignore_errors=True)
424
+ raise ManifestError("git clone timed out")
425
+ manifest = _read_manifest_file(Path(tmp))
426
+ return manifest, tmp
427
+
428
+ if _looks_like_url(source):
429
+ # Raw file URL. Use urllib so there is no shell involvement.
430
+ import urllib.request
431
+
432
+ parsed = urlparse(source)
433
+ if parsed.scheme not in ("http", "https"):
434
+ raise ManifestError(f"unsupported URL scheme: {parsed.scheme!r}")
435
+ try:
436
+ req = urllib.request.Request(source, headers={"User-Agent": "loki-hub"})
437
+ with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310
438
+ raw = resp.read(_MAX_MANIFEST_BYTES + 1)
439
+ except Exception as e:
440
+ raise ManifestError(f"failed to fetch URL: {e}")
441
+ if len(raw) > _MAX_MANIFEST_BYTES:
442
+ raise ManifestError("remote manifest too large")
443
+ try:
444
+ return json.loads(raw.decode("utf-8")), None
445
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
446
+ raise ManifestError(f"remote manifest is not valid JSON: {e}")
447
+
448
+ raise ManifestError(f"unrecognized source (not a path, git, or url): {source!r}")
449
+
450
+
451
+ def _resolve_template_body(manifest: Dict[str, Any], base_dir: Optional[str]) -> Dict[str, Any]:
452
+ """If the manifest uses body_file, inline it from base_dir (data read only)."""
453
+ if "body" in manifest:
454
+ return manifest
455
+ body_file = manifest.get("body_file")
456
+ if not isinstance(body_file, str) or not body_file:
457
+ return manifest
458
+ # body_file must stay inside base_dir (no traversal).
459
+ if base_dir is None:
460
+ raise ManifestError("body_file requires a local or git source")
461
+ safe_name = _assert_safe_id(Path(body_file).stem, "body_file name")
462
+ candidate = Path(base_dir) / f"{safe_name}.md"
463
+ if not candidate.exists():
464
+ raise ManifestError(f"body_file not found: {body_file}")
465
+ if candidate.stat().st_size > _MAX_TEMPLATE_BODY_LEN:
466
+ raise ManifestError("body_file too large")
467
+ out = dict(manifest)
468
+ out["body"] = candidate.read_text(encoding="utf-8")
469
+ return out
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # Install: agent
474
+ # ---------------------------------------------------------------------------
475
+
476
+
477
+ def _load_installed_agents() -> List[Dict[str, Any]]:
478
+ p = installed_agents_path()
479
+ if not p.exists():
480
+ return []
481
+ try:
482
+ with open(p, "r", encoding="utf-8") as f:
483
+ data = json.load(f)
484
+ except (OSError, json.JSONDecodeError):
485
+ return []
486
+ return [d for d in data if isinstance(d, dict)] if isinstance(data, list) else []
487
+
488
+
489
+ def _save_installed_agents(agents: List[Dict[str, Any]]) -> None:
490
+ p = installed_agents_path()
491
+ p.parent.mkdir(parents=True, exist_ok=True)
492
+ tmp = p.with_suffix(".json.tmp")
493
+ with open(tmp, "w", encoding="utf-8") as f:
494
+ json.dump(agents, f, indent=2, sort_keys=True)
495
+ os.replace(tmp, p)
496
+
497
+
498
+ def install_agent(source: str) -> Dict[str, Any]:
499
+ """Validate and install an agent manifest from source. Returns the entry."""
500
+ manifest, _base = resolve_source(source)
501
+ exec_fields = _report_executable_fields(manifest) if isinstance(manifest, dict) else []
502
+ entry = validate_agent_manifest(manifest)
503
+
504
+ installed = _load_installed_agents()
505
+ installed = [a for a in installed if a.get("type") != entry["type"]]
506
+ installed.append(entry)
507
+ _save_installed_agents(installed)
508
+
509
+ entry = dict(entry)
510
+ entry["_ignored_executable_fields"] = exec_fields
511
+ return entry
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Install: template
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ def _load_installed_templates() -> List[Dict[str, Any]]:
520
+ p = installed_templates_index()
521
+ if not p.exists():
522
+ return []
523
+ try:
524
+ with open(p, "r", encoding="utf-8") as f:
525
+ data = json.load(f)
526
+ except (OSError, json.JSONDecodeError):
527
+ return []
528
+ return [d for d in data if isinstance(d, dict)] if isinstance(data, list) else []
529
+
530
+
531
+ def _save_installed_templates(items: List[Dict[str, Any]]) -> None:
532
+ p = installed_templates_index()
533
+ p.parent.mkdir(parents=True, exist_ok=True)
534
+ tmp = p.with_suffix(".json.tmp")
535
+ with open(tmp, "w", encoding="utf-8") as f:
536
+ json.dump(items, f, indent=2, sort_keys=True)
537
+ os.replace(tmp, p)
538
+
539
+
540
+ def install_template(source: str) -> Dict[str, Any]:
541
+ """Validate and install a template manifest from source. Returns metadata."""
542
+ manifest, base = resolve_source(source)
543
+ exec_fields = _report_executable_fields(manifest) if isinstance(manifest, dict) else []
544
+ if isinstance(manifest, dict):
545
+ manifest = _resolve_template_body(manifest, base)
546
+ metadata, body = validate_template_manifest(manifest)
547
+
548
+ tdir = installed_templates_dir()
549
+ tdir.mkdir(parents=True, exist_ok=True)
550
+ # name already validated as a safe id, so this path cannot escape tdir.
551
+ body_path = tdir / f"{metadata['name']}.md"
552
+ tmp = body_path.with_suffix(".md.tmp")
553
+ with open(tmp, "w", encoding="utf-8") as f:
554
+ f.write(body)
555
+ os.replace(tmp, body_path)
556
+
557
+ items = _load_installed_templates()
558
+ items = [t for t in items if t.get("name") != metadata["name"]]
559
+ items.append(metadata)
560
+ _save_installed_templates(items)
561
+
562
+ metadata = dict(metadata)
563
+ metadata["_ignored_executable_fields"] = exec_fields
564
+ metadata["path"] = str(body_path)
565
+ return metadata
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Read helpers used by cmd_agent / cmd_init to union built-in + installed
570
+ # ---------------------------------------------------------------------------
571
+
572
+
573
+ def merged_agent_types() -> List[Dict[str, Any]]:
574
+ """Built-in agents (from types.json) plus installed agents."""
575
+ builtins: List[Dict[str, Any]] = []
576
+ try:
577
+ with open(_builtin_types_path(), "r", encoding="utf-8") as f:
578
+ data = json.load(f)
579
+ if isinstance(data, list):
580
+ builtins = [d for d in data if isinstance(d, dict)]
581
+ except (OSError, json.JSONDecodeError):
582
+ builtins = []
583
+ seen = {b.get("type") for b in builtins}
584
+ for inst in _load_installed_agents():
585
+ if inst.get("type") not in seen:
586
+ builtins.append(inst)
587
+ return builtins
588
+
589
+
590
+ def installed_agent_list() -> List[Dict[str, Any]]:
591
+ return _load_installed_agents()
592
+
593
+
594
+ def installed_template_list() -> List[Dict[str, Any]]:
595
+ return _load_installed_templates()
596
+
597
+
598
+ def installed_template_path(name: str) -> Optional[str]:
599
+ """Resolve an installed template body path by name, or None."""
600
+ try:
601
+ safe = _assert_safe_id(name, "template name")
602
+ except ManifestError:
603
+ return None
604
+ p = installed_templates_dir() / f"{safe}.md"
605
+ return str(p) if p.exists() else None
606
+
607
+
608
+ # ---------------------------------------------------------------------------
609
+ # CLI entry (invoked by bash via: python3 hub_install.py <cmd> [args])
610
+ # ---------------------------------------------------------------------------
611
+
612
+
613
+ def _main(argv: List[str]) -> int:
614
+ import sys
615
+
616
+ if not argv:
617
+ print("usage: hub_install.py <install-agent|install-template|"
618
+ "list-agents|list-templates|merged-agents> [source]", file=sys.stderr)
619
+ return 2
620
+ cmd = argv[0]
621
+ try:
622
+ if cmd == "install-agent":
623
+ entry = install_agent(argv[1])
624
+ print(json.dumps(entry))
625
+ return 0
626
+ if cmd == "install-template":
627
+ meta = install_template(argv[1])
628
+ print(json.dumps(meta))
629
+ return 0
630
+ if cmd == "list-agents":
631
+ print(json.dumps(installed_agent_list()))
632
+ return 0
633
+ if cmd == "list-templates":
634
+ print(json.dumps(installed_template_list()))
635
+ return 0
636
+ if cmd == "merged-agents":
637
+ print(json.dumps(merged_agent_types()))
638
+ return 0
639
+ except ManifestError as e:
640
+ print(f"MANIFEST_ERROR: {e}", file=sys.stderr)
641
+ return 1
642
+ except IndexError:
643
+ print("missing source argument", file=sys.stderr)
644
+ return 2
645
+ print(f"unknown command: {cmd}", file=sys.stderr)
646
+ return 2
647
+
648
+
649
+ if __name__ == "__main__":
650
+ import sys
651
+
652
+ raise SystemExit(_main(sys.argv[1:]))