loki-mode 7.16.0 → 7.17.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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/agents/hub_install.py +652 -0
- package/autonomy/lib/assets_bundle.py +446 -0
- package/autonomy/loki +450 -8
- package/dashboard/__init__.py +1 -1
- package/dashboard/static/index.html +12 -17
- package/docs/INSTALLATION.md +1 -1
- package/docs/R10-MARKETPLACE-PLAN.md +137 -0
- package/docs/R8-SHAREABLE-TEAM-ASSETS-PLAN.md +129 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
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.
|
|
6
|
+
# Loki Mode v7.17.0
|
|
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.
|
|
386
|
+
**v7.17.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.17.0
|
|
@@ -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:]))
|