@sakanano-moe/agenthelm 0.0.0-stage → 6.5.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/LICENSE +21 -0
- package/bin/agenthelm.js +29 -0
- package/package.json +34 -4
- package/runtime/agent_pack_config.py +1573 -0
- package/runtime/agenthelm.py +1051 -0
- package/runtime/mcp_env_launcher.py +94 -0
- package/runtime/mcp_server.py +285 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1573 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Portable Agent Plugin / Skill / MCP inventory and transfer runtime.
|
|
3
|
+
|
|
4
|
+
The module intentionally uses only the Python standard library so it can run
|
|
5
|
+
from an Agent Plugin without creating a package-local virtual environment.
|
|
6
|
+
Writes are always opt-in through ``apply=True`` and collisions fail closed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import tomllib
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from dataclasses import asdict, dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Iterable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
|
28
|
+
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
|
|
29
|
+
PROJECT_PROFILE_NAMESPACE = "moe.sakanano.agent-pack"
|
|
30
|
+
PROJECT_PROFILE_RELATIVE = Path(".agents") / PROJECT_PROFILE_NAMESPACE / "project.json"
|
|
31
|
+
PLUGIN_NAME_RE = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
|
32
|
+
SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
33
|
+
SECRET_NAME_RE = re.compile(
|
|
34
|
+
r"(?:secret|token|password|passwd|api[_-]?key|authorization|cookie|credential)",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
SECRET_VALUE_RE = re.compile(
|
|
38
|
+
r"(?:bearer\s+\S+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|"
|
|
39
|
+
r"AKIA[A-Z0-9]{16})",
|
|
40
|
+
re.IGNORECASE,
|
|
41
|
+
)
|
|
42
|
+
ENV_REFERENCE_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
43
|
+
# Placeholders the client resolves at launch time (immutable package root and
|
|
44
|
+
# client-managed writable state). They are never credentials and must not
|
|
45
|
+
# trigger the credential-env-file requirement or env-launcher wrapping.
|
|
46
|
+
RESERVED_ENV_PLACEHOLDERS = {"PLUGIN_ROOT", "PLUGIN_DATA"}
|
|
47
|
+
COPY_IGNORE = {".git", ".venv", "__pycache__", "node_modules", ".DS_Store"}
|
|
48
|
+
PLUGIN_FIELDS = {
|
|
49
|
+
"$schema", "name", "version", "description", "author", "homepage",
|
|
50
|
+
"repository", "license", "keywords", "extensions",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AgentPackError(RuntimeError):
|
|
55
|
+
"""A user-actionable configuration or transfer error."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SkillRecord:
|
|
60
|
+
name: str
|
|
61
|
+
path: str
|
|
62
|
+
source: str
|
|
63
|
+
portable: bool = True
|
|
64
|
+
issues: tuple[str, ...] = ()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class McpRecord:
|
|
69
|
+
name: str
|
|
70
|
+
config: dict[str, Any]
|
|
71
|
+
source: str
|
|
72
|
+
portable: bool
|
|
73
|
+
issues: tuple[str, ...] = ()
|
|
74
|
+
authority: str = "portable"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Inventory:
|
|
79
|
+
source: str
|
|
80
|
+
plugins: list[dict[str, Any]] = field(default_factory=list)
|
|
81
|
+
skills: list[SkillRecord] = field(default_factory=list)
|
|
82
|
+
mcps: list[McpRecord] = field(default_factory=list)
|
|
83
|
+
issues: list[str] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"source": self.source,
|
|
88
|
+
"plugins": self.plugins,
|
|
89
|
+
"skills": [asdict(item) for item in self.skills],
|
|
90
|
+
"mcps": [asdict(item) for item in self.mcps],
|
|
91
|
+
"issues": self.issues,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ProjectLayout:
|
|
97
|
+
root: Path
|
|
98
|
+
profile_path: Path | None
|
|
99
|
+
plugin_roots: tuple[Path, ...]
|
|
100
|
+
plugin_dirs: tuple[Path, ...]
|
|
101
|
+
skill_roots: tuple[Path, ...]
|
|
102
|
+
mcp_sources: tuple[Path, ...]
|
|
103
|
+
native_mcp_sources: tuple[Path, ...]
|
|
104
|
+
credential_env_file: Path | None
|
|
105
|
+
mcp_client_policy: dict[str, dict[str, tuple[str, ...]]]
|
|
106
|
+
destination_skill_root: Path
|
|
107
|
+
destination_mcp: Path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
111
|
+
if path.is_symlink():
|
|
112
|
+
raise AgentPackError(f"refusing symlinked JSON: {path}")
|
|
113
|
+
try:
|
|
114
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
115
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
116
|
+
raise AgentPackError(f"invalid JSON {path}: {exc}") from exc
|
|
117
|
+
if not isinstance(value, dict):
|
|
118
|
+
raise AgentPackError(f"JSON root must be an object: {path}")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_jsonc(path: Path) -> dict[str, Any]:
|
|
123
|
+
if path.is_symlink():
|
|
124
|
+
raise AgentPackError(f"refusing symlinked JSONC: {path}")
|
|
125
|
+
try:
|
|
126
|
+
raw = path.read_text(encoding="utf-8")
|
|
127
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
128
|
+
raise AgentPackError(f"cannot read {path}: {exc}") from exc
|
|
129
|
+
out: list[str] = []
|
|
130
|
+
in_string = False
|
|
131
|
+
escaped = False
|
|
132
|
+
index = 0
|
|
133
|
+
while index < len(raw):
|
|
134
|
+
char = raw[index]
|
|
135
|
+
if escaped:
|
|
136
|
+
out.append(char)
|
|
137
|
+
escaped = False
|
|
138
|
+
index += 1
|
|
139
|
+
continue
|
|
140
|
+
if char == "\\" and in_string:
|
|
141
|
+
out.append(char)
|
|
142
|
+
escaped = True
|
|
143
|
+
index += 1
|
|
144
|
+
continue
|
|
145
|
+
if char == '"':
|
|
146
|
+
in_string = not in_string
|
|
147
|
+
out.append(char)
|
|
148
|
+
index += 1
|
|
149
|
+
continue
|
|
150
|
+
if not in_string and raw[index:index + 2] == "//":
|
|
151
|
+
while index < len(raw) and raw[index] not in "\r\n":
|
|
152
|
+
index += 1
|
|
153
|
+
continue
|
|
154
|
+
out.append(char)
|
|
155
|
+
index += 1
|
|
156
|
+
cleaned = re.sub(r",\s*([}\]])", r"\1", "".join(out))
|
|
157
|
+
try:
|
|
158
|
+
value = json.loads(cleaned)
|
|
159
|
+
except json.JSONDecodeError as exc:
|
|
160
|
+
raise AgentPackError(f"invalid JSONC {path}: {exc}") from exc
|
|
161
|
+
if not isinstance(value, dict):
|
|
162
|
+
raise AgentPackError(f"JSONC root must be an object: {path}")
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _write_json_atomic(path: Path, value: dict[str, Any], *, mode: int = 0o644) -> None:
|
|
167
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
payload = json.dumps(value, indent=2, ensure_ascii=False) + "\n"
|
|
169
|
+
descriptor, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
170
|
+
temp = Path(temp_name)
|
|
171
|
+
try:
|
|
172
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
173
|
+
handle.write(payload)
|
|
174
|
+
handle.flush()
|
|
175
|
+
os.fsync(handle.fileno())
|
|
176
|
+
os.chmod(temp, mode)
|
|
177
|
+
os.replace(temp, path)
|
|
178
|
+
finally:
|
|
179
|
+
if temp.exists():
|
|
180
|
+
temp.unlink()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _contained(root: Path, candidate: Path) -> Path:
|
|
184
|
+
root = root.resolve()
|
|
185
|
+
resolved = candidate.resolve()
|
|
186
|
+
try:
|
|
187
|
+
resolved.relative_to(root)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise AgentPackError(f"path escapes project root: {candidate}") from exc
|
|
190
|
+
return resolved
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _resolve_profile_paths(root: Path, values: Any, defaults: list[str]) -> tuple[Path, ...]:
|
|
194
|
+
raw_values = values if isinstance(values, list) else defaults
|
|
195
|
+
paths: list[Path] = []
|
|
196
|
+
for value in raw_values:
|
|
197
|
+
if not isinstance(value, str) or not value.strip():
|
|
198
|
+
raise AgentPackError("project profile paths must be non-empty strings")
|
|
199
|
+
candidate = root / value
|
|
200
|
+
_contained(root, candidate.parent if not candidate.exists() else candidate)
|
|
201
|
+
paths.append(candidate)
|
|
202
|
+
return tuple(paths)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def find_project_root(start: Path) -> Path:
|
|
206
|
+
current = start.expanduser().resolve()
|
|
207
|
+
if current.is_file():
|
|
208
|
+
current = current.parent
|
|
209
|
+
for candidate in (current, *current.parents):
|
|
210
|
+
if (candidate / PROJECT_PROFILE_RELATIVE).is_file():
|
|
211
|
+
return candidate
|
|
212
|
+
if (candidate / "AGENTS.md").is_file() and (candidate / ".git").exists():
|
|
213
|
+
return candidate
|
|
214
|
+
raise AgentPackError(f"no initialized project found from {start}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_project_layout(project: Path) -> ProjectLayout:
|
|
218
|
+
root = find_project_root(project)
|
|
219
|
+
profile_path = root / PROJECT_PROFILE_RELATIVE
|
|
220
|
+
config: dict[str, Any] = {}
|
|
221
|
+
if profile_path.is_file():
|
|
222
|
+
profile = _read_json(profile_path)
|
|
223
|
+
if profile.get("schema_version") != "1.0":
|
|
224
|
+
raise AgentPackError(f"unsupported project profile schema: {profile.get('schema_version')!r}")
|
|
225
|
+
config_value = profile.get("capabilities", {})
|
|
226
|
+
if isinstance(config_value, dict):
|
|
227
|
+
config = config_value
|
|
228
|
+
|
|
229
|
+
plugin_roots = _resolve_profile_paths(root, config.get("plugin_roots"), [".agents"])
|
|
230
|
+
plugin_dirs = _resolve_profile_paths(root, config.get("plugin_dirs"), [])
|
|
231
|
+
skill_roots = _resolve_profile_paths(root, config.get("skill_roots"), [".agents/skills"])
|
|
232
|
+
mcp_sources = _resolve_profile_paths(root, config.get("mcp_sources"), [".agents/mcp.json"])
|
|
233
|
+
native_mcp_sources = _resolve_profile_paths(root, config.get("native_mcp_sources"), [])
|
|
234
|
+
credential_value = config.get("credential_env_file")
|
|
235
|
+
credential_env_file = None
|
|
236
|
+
if credential_value is not None:
|
|
237
|
+
credential_env_file = _resolve_profile_paths(root, [credential_value], [])[0]
|
|
238
|
+
policy_value = config.get("mcp_client_policy", {})
|
|
239
|
+
if not isinstance(policy_value, dict):
|
|
240
|
+
raise AgentPackError("mcp_client_policy must be an object")
|
|
241
|
+
mcp_client_policy: dict[str, dict[str, tuple[str, ...]]] = {}
|
|
242
|
+
for name, raw_policy in policy_value.items():
|
|
243
|
+
if not isinstance(name, str) or not isinstance(raw_policy, dict):
|
|
244
|
+
raise AgentPackError("MCP client policies must map names to objects")
|
|
245
|
+
unknown = set(raw_policy) - {"include", "exclude"}
|
|
246
|
+
if unknown:
|
|
247
|
+
raise AgentPackError(f"MCP policy {name} has unknown fields: {', '.join(sorted(unknown))}")
|
|
248
|
+
parsed: dict[str, tuple[str, ...]] = {}
|
|
249
|
+
for mode in ("include", "exclude"):
|
|
250
|
+
values = raw_policy.get(mode, [])
|
|
251
|
+
if not isinstance(values, list) or not all(isinstance(value, str) and value for value in values):
|
|
252
|
+
raise AgentPackError(f"MCP policy {name}.{mode} must be a string array")
|
|
253
|
+
parsed[mode] = tuple(values)
|
|
254
|
+
if parsed["include"] and parsed["exclude"]:
|
|
255
|
+
raise AgentPackError(f"MCP policy {name} cannot set both include and exclude")
|
|
256
|
+
mcp_client_policy[name] = parsed
|
|
257
|
+
destination_skill = _resolve_profile_paths(
|
|
258
|
+
root, [config.get("destination_skill_root", ".agents/skills")], [".agents/skills"]
|
|
259
|
+
)[0]
|
|
260
|
+
destination_mcp = _resolve_profile_paths(
|
|
261
|
+
root, [config.get("destination_mcp", ".agents/mcp.json")], [".agents/mcp.json"]
|
|
262
|
+
)[0]
|
|
263
|
+
return ProjectLayout(
|
|
264
|
+
root=root,
|
|
265
|
+
profile_path=profile_path if profile_path.is_file() else None,
|
|
266
|
+
plugin_roots=plugin_roots,
|
|
267
|
+
plugin_dirs=plugin_dirs,
|
|
268
|
+
skill_roots=skill_roots,
|
|
269
|
+
mcp_sources=mcp_sources,
|
|
270
|
+
native_mcp_sources=native_mcp_sources,
|
|
271
|
+
credential_env_file=credential_env_file,
|
|
272
|
+
mcp_client_policy=mcp_client_policy,
|
|
273
|
+
destination_skill_root=destination_skill,
|
|
274
|
+
destination_mcp=destination_mcp,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def validate_plugin_manifest(plugin_root: Path) -> tuple[dict[str, Any], list[str]]:
|
|
279
|
+
manifest_path = plugin_root / "plugin.json"
|
|
280
|
+
data = _read_json(manifest_path)
|
|
281
|
+
issues: list[str] = []
|
|
282
|
+
if data.get("$schema") != AGENT_PLUGIN_SCHEMA:
|
|
283
|
+
issues.append("plugin.json must target Agent Plugins 1.0.0")
|
|
284
|
+
name = data.get("name")
|
|
285
|
+
if not isinstance(name, str) or not PLUGIN_NAME_RE.fullmatch(name):
|
|
286
|
+
issues.append(f"invalid plugin name: {name!r}")
|
|
287
|
+
unknown = sorted(set(data) - PLUGIN_FIELDS)
|
|
288
|
+
if unknown:
|
|
289
|
+
issues.append("unknown plugin.json fields: " + ", ".join(unknown))
|
|
290
|
+
return data, issues
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _portable_mcp(name: str, raw: Any) -> McpRecord:
|
|
294
|
+
if not isinstance(raw, dict):
|
|
295
|
+
return McpRecord(name, {}, "", False, ("MCP entry must be an object",))
|
|
296
|
+
item = dict(raw)
|
|
297
|
+
issues: list[str] = []
|
|
298
|
+
mcp_type = item.get("type")
|
|
299
|
+
if mcp_type == "http":
|
|
300
|
+
item["type"] = "streamable-http"
|
|
301
|
+
mcp_type = "streamable-http"
|
|
302
|
+
if mcp_type is None:
|
|
303
|
+
if isinstance(item.get("command"), str):
|
|
304
|
+
item["type"] = "stdio"
|
|
305
|
+
mcp_type = "stdio"
|
|
306
|
+
elif isinstance(item.get("url"), str):
|
|
307
|
+
item["type"] = "streamable-http"
|
|
308
|
+
mcp_type = "streamable-http"
|
|
309
|
+
if mcp_type not in {"stdio", "streamable-http", "sse"}:
|
|
310
|
+
issues.append(f"unsupported transport: {mcp_type!r}")
|
|
311
|
+
|
|
312
|
+
for container_name in ("env", "headers"):
|
|
313
|
+
container = item.get(container_name, {})
|
|
314
|
+
if container is None:
|
|
315
|
+
continue
|
|
316
|
+
if not isinstance(container, dict):
|
|
317
|
+
issues.append(f"{container_name} must be an object")
|
|
318
|
+
continue
|
|
319
|
+
for key, value in container.items():
|
|
320
|
+
references = ENV_REFERENCE_RE.findall(str(value))
|
|
321
|
+
safe_reference = bool(references) and not SECRET_VALUE_RE.search(
|
|
322
|
+
ENV_REFERENCE_RE.sub("", str(value))
|
|
323
|
+
)
|
|
324
|
+
if (SECRET_NAME_RE.search(str(key)) or SECRET_VALUE_RE.search(str(value))) and not safe_reference:
|
|
325
|
+
issues.append(f"possible secret in {container_name}.{key}")
|
|
326
|
+
args = item.get("args", [])
|
|
327
|
+
if isinstance(args, list):
|
|
328
|
+
for index, value in enumerate(args):
|
|
329
|
+
rendered = str(value)
|
|
330
|
+
safe_reference = bool(ENV_REFERENCE_RE.search(rendered)) and not SECRET_VALUE_RE.search(
|
|
331
|
+
ENV_REFERENCE_RE.sub("", rendered)
|
|
332
|
+
)
|
|
333
|
+
if (SECRET_NAME_RE.search(rendered) or SECRET_VALUE_RE.search(rendered)) and not safe_reference:
|
|
334
|
+
issues.append(f"possible secret reference in args[{index}]")
|
|
335
|
+
if mcp_type == "stdio":
|
|
336
|
+
allowed = {"type", "command", "args", "env", "cwd"}
|
|
337
|
+
if not isinstance(item.get("command"), str) or not item["command"]:
|
|
338
|
+
issues.append("stdio command is required")
|
|
339
|
+
else:
|
|
340
|
+
allowed = {"type", "url", "headers"}
|
|
341
|
+
if not isinstance(item.get("url"), str) or not item["url"]:
|
|
342
|
+
issues.append("remote URL is required")
|
|
343
|
+
unknown = sorted(set(item) - allowed)
|
|
344
|
+
if unknown:
|
|
345
|
+
issues.append("unsupported fields: " + ", ".join(unknown))
|
|
346
|
+
return McpRecord(name, item, "", not issues, tuple(issues))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _inventory_plugin(plugin_root: Path, source: str) -> Inventory:
|
|
350
|
+
inventory = Inventory(source=source)
|
|
351
|
+
try:
|
|
352
|
+
manifest, manifest_issues = validate_plugin_manifest(plugin_root)
|
|
353
|
+
except AgentPackError as exc:
|
|
354
|
+
inventory.issues.append(str(exc))
|
|
355
|
+
return inventory
|
|
356
|
+
inventory.plugins.append({
|
|
357
|
+
"name": manifest.get("name"),
|
|
358
|
+
"version": manifest.get("version"),
|
|
359
|
+
"path": str(plugin_root),
|
|
360
|
+
"issues": manifest_issues,
|
|
361
|
+
})
|
|
362
|
+
inventory.issues.extend(manifest_issues)
|
|
363
|
+
|
|
364
|
+
skills_root = plugin_root / "skills"
|
|
365
|
+
if skills_root.is_dir() and not skills_root.is_symlink():
|
|
366
|
+
for child in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
|
367
|
+
if child.name.startswith(".") or not child.is_dir():
|
|
368
|
+
continue
|
|
369
|
+
skill_file = child / "SKILL.md"
|
|
370
|
+
issues: list[str] = []
|
|
371
|
+
if not SKILL_NAME_RE.fullmatch(child.name):
|
|
372
|
+
issues.append("non-portable skill directory name")
|
|
373
|
+
if not skill_file.is_file() or skill_file.is_symlink():
|
|
374
|
+
issues.append("SKILL.md missing or symlinked")
|
|
375
|
+
inventory.skills.append(SkillRecord(
|
|
376
|
+
child.name, str(child), source, not issues, tuple(issues)
|
|
377
|
+
))
|
|
378
|
+
|
|
379
|
+
mcp_path = plugin_root / "mcp.json"
|
|
380
|
+
if mcp_path.is_file():
|
|
381
|
+
try:
|
|
382
|
+
payload = _read_json(mcp_path)
|
|
383
|
+
if payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA:
|
|
384
|
+
inventory.issues.append(f"{mcp_path}: MCP schema is not Agent Plugins 1.0.0")
|
|
385
|
+
servers = payload.get("mcpServers", {})
|
|
386
|
+
if not isinstance(servers, dict):
|
|
387
|
+
raise AgentPackError(f"mcpServers must be an object: {mcp_path}")
|
|
388
|
+
for name, raw in sorted(servers.items()):
|
|
389
|
+
record = _portable_mcp(name, raw)
|
|
390
|
+
inventory.mcps.append(McpRecord(
|
|
391
|
+
record.name, record.config, source, record.portable, record.issues, "portable"
|
|
392
|
+
))
|
|
393
|
+
except AgentPackError as exc:
|
|
394
|
+
inventory.issues.append(str(exc))
|
|
395
|
+
return inventory
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _merge_inventory(target: Inventory, source: Inventory) -> None:
|
|
399
|
+
target.plugins.extend(source.plugins)
|
|
400
|
+
target.skills.extend(source.skills)
|
|
401
|
+
target.mcps.extend(source.mcps)
|
|
402
|
+
target.issues.extend(source.issues)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def inventory_project(project: Path) -> Inventory:
|
|
406
|
+
layout = load_project_layout(project)
|
|
407
|
+
inventory = Inventory(source=f"project:{layout.root}")
|
|
408
|
+
visited_plugins: set[Path] = set()
|
|
409
|
+
for plugin_root in layout.plugin_roots:
|
|
410
|
+
if (plugin_root / "plugin.json").is_file():
|
|
411
|
+
resolved = plugin_root.resolve()
|
|
412
|
+
visited_plugins.add(resolved)
|
|
413
|
+
_merge_inventory(inventory, _inventory_plugin(plugin_root, inventory.source))
|
|
414
|
+
for plugin_dir in layout.plugin_dirs:
|
|
415
|
+
if not plugin_dir.is_dir() or plugin_dir.is_symlink():
|
|
416
|
+
continue
|
|
417
|
+
for child in sorted(plugin_dir.iterdir(), key=lambda item: item.name):
|
|
418
|
+
if child.is_dir() and (child / "plugin.json").is_file():
|
|
419
|
+
resolved = child.resolve()
|
|
420
|
+
if resolved not in visited_plugins:
|
|
421
|
+
visited_plugins.add(resolved)
|
|
422
|
+
_merge_inventory(inventory, _inventory_plugin(child, inventory.source))
|
|
423
|
+
|
|
424
|
+
known_skill_paths = {Path(item.path).resolve() for item in inventory.skills}
|
|
425
|
+
for skill_root in layout.skill_roots:
|
|
426
|
+
if not skill_root.is_dir() or skill_root.is_symlink():
|
|
427
|
+
continue
|
|
428
|
+
for child in sorted(skill_root.iterdir(), key=lambda item: item.name):
|
|
429
|
+
if not child.is_dir() or not (child / "SKILL.md").is_file():
|
|
430
|
+
continue
|
|
431
|
+
resolved = child.resolve()
|
|
432
|
+
if resolved in known_skill_paths:
|
|
433
|
+
continue
|
|
434
|
+
issues = () if SKILL_NAME_RE.fullmatch(child.name) else ("non-portable skill name",)
|
|
435
|
+
inventory.skills.append(SkillRecord(
|
|
436
|
+
child.name, str(child), inventory.source, not issues, issues
|
|
437
|
+
))
|
|
438
|
+
known_skill_paths.add(resolved)
|
|
439
|
+
|
|
440
|
+
known_mcp_names = {item.name for item in inventory.mcps}
|
|
441
|
+
for mcp_source in (*layout.mcp_sources, *layout.native_mcp_sources):
|
|
442
|
+
if not mcp_source.is_file() or mcp_source.name == "mcp.json" and mcp_source.parent.resolve() in visited_plugins:
|
|
443
|
+
continue
|
|
444
|
+
try:
|
|
445
|
+
payload = _read_jsonc(mcp_source) if mcp_source.suffix == ".jsonc" else _read_json(mcp_source)
|
|
446
|
+
servers = payload.get("mcpServers", {})
|
|
447
|
+
if not isinstance(servers, dict):
|
|
448
|
+
raise AgentPackError(f"mcpServers must be an object: {mcp_source}")
|
|
449
|
+
for name, raw in sorted(servers.items()):
|
|
450
|
+
if name in known_mcp_names:
|
|
451
|
+
inventory.issues.append(f"duplicate MCP name: {name}")
|
|
452
|
+
continue
|
|
453
|
+
record = _portable_mcp(name, raw)
|
|
454
|
+
authority = "native" if mcp_source in layout.native_mcp_sources else "portable"
|
|
455
|
+
inventory.mcps.append(McpRecord(
|
|
456
|
+
record.name, record.config, str(mcp_source), record.portable, record.issues, authority
|
|
457
|
+
))
|
|
458
|
+
known_mcp_names.add(name)
|
|
459
|
+
except AgentPackError as exc:
|
|
460
|
+
inventory.issues.append(str(exc))
|
|
461
|
+
|
|
462
|
+
_add_duplicate_issues(inventory)
|
|
463
|
+
return inventory
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _client_skill_roots(client: str, home: Path) -> list[Path]:
|
|
467
|
+
mapping = {
|
|
468
|
+
"codex": [home / ".codex" / "skills"],
|
|
469
|
+
"cursor": [home / ".cursor" / "skills"],
|
|
470
|
+
"claude": [home / ".claude" / "skills"],
|
|
471
|
+
"codebuddy": [home / ".codebuddy" / "skills"],
|
|
472
|
+
}
|
|
473
|
+
if client not in mapping:
|
|
474
|
+
raise AgentPackError(f"unsupported client: {client}")
|
|
475
|
+
return mapping[client]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _client_plugin_roots(client: str, home: Path) -> list[Path]:
|
|
479
|
+
if client == "cursor":
|
|
480
|
+
return [home / ".cursor" / "plugins" / "local"]
|
|
481
|
+
if client == "codex":
|
|
482
|
+
return [home / ".agents" / "plugins" / "plugins"]
|
|
483
|
+
if client == "claude":
|
|
484
|
+
return [_claude_plugin_records(home)]
|
|
485
|
+
return []
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _claude_plugin_records(home: Path) -> Path:
|
|
489
|
+
"""Install records for Claude Code: one symlink per plugin to its source root."""
|
|
490
|
+
return home / ".claude" / "agent-pack" / "plugins"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _claude_user_config(home: Path) -> Path:
|
|
494
|
+
"""Claude Code keeps user-scope MCP servers in ~/.claude.json#mcpServers."""
|
|
495
|
+
return home / ".claude.json"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _codex_mcps(home: Path) -> dict[str, Any]:
|
|
499
|
+
path = home / ".codex" / "config.toml"
|
|
500
|
+
if not path.is_file() or path.is_symlink():
|
|
501
|
+
return {}
|
|
502
|
+
try:
|
|
503
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
504
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
|
|
505
|
+
return {}
|
|
506
|
+
servers = data.get("mcp_servers", {})
|
|
507
|
+
return servers if isinstance(servers, dict) else {}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _json_client_mcps(path: Path) -> dict[str, Any]:
|
|
511
|
+
if not path.is_file() or path.is_symlink():
|
|
512
|
+
return {}
|
|
513
|
+
try:
|
|
514
|
+
data = _read_json(path)
|
|
515
|
+
except AgentPackError:
|
|
516
|
+
return {}
|
|
517
|
+
servers = data.get("mcpServers", {})
|
|
518
|
+
return servers if isinstance(servers, dict) else {}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def inventory_client(client: str, home: Path | None = None) -> Inventory:
|
|
522
|
+
home = (home or Path.home()).expanduser().resolve()
|
|
523
|
+
inventory = Inventory(source=f"client:{client}")
|
|
524
|
+
for plugin_parent in _client_plugin_roots(client, home):
|
|
525
|
+
if not plugin_parent.is_dir() or plugin_parent.is_symlink():
|
|
526
|
+
continue
|
|
527
|
+
for child in sorted(plugin_parent.iterdir(), key=lambda item: item.name):
|
|
528
|
+
if child.is_dir() and (child / "plugin.json").is_file():
|
|
529
|
+
_merge_inventory(inventory, _inventory_plugin(child, inventory.source))
|
|
530
|
+
known = {Path(item.path).resolve() for item in inventory.skills}
|
|
531
|
+
for skill_root in _client_skill_roots(client, home):
|
|
532
|
+
if not skill_root.is_dir() or skill_root.is_symlink():
|
|
533
|
+
continue
|
|
534
|
+
for child in sorted(skill_root.iterdir(), key=lambda item: item.name):
|
|
535
|
+
try:
|
|
536
|
+
valid = child.is_dir() and (child / "SKILL.md").is_file()
|
|
537
|
+
except OSError:
|
|
538
|
+
valid = False
|
|
539
|
+
if not valid:
|
|
540
|
+
continue
|
|
541
|
+
resolved = child.resolve()
|
|
542
|
+
if resolved in known:
|
|
543
|
+
continue
|
|
544
|
+
issues = () if SKILL_NAME_RE.fullmatch(child.name) else ("non-portable skill name",)
|
|
545
|
+
inventory.skills.append(SkillRecord(
|
|
546
|
+
child.name, str(child), inventory.source, not issues, issues
|
|
547
|
+
))
|
|
548
|
+
known.add(resolved)
|
|
549
|
+
if client == "codex":
|
|
550
|
+
servers = _codex_mcps(home)
|
|
551
|
+
elif client == "cursor":
|
|
552
|
+
servers = _json_client_mcps(home / ".cursor" / "mcp.json")
|
|
553
|
+
elif client == "claude":
|
|
554
|
+
servers = _json_client_mcps(_claude_user_config(home))
|
|
555
|
+
else:
|
|
556
|
+
servers = _json_client_mcps(home / ".codebuddy" / "mcp.json")
|
|
557
|
+
for name, raw in sorted(servers.items()):
|
|
558
|
+
record = _portable_mcp(name, raw)
|
|
559
|
+
inventory.mcps.append(McpRecord(
|
|
560
|
+
record.name, record.config, inventory.source, record.portable, record.issues, "client"
|
|
561
|
+
))
|
|
562
|
+
_add_duplicate_issues(inventory)
|
|
563
|
+
return inventory
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def inventory_source(locator: str, home: Path | None = None) -> Inventory:
|
|
567
|
+
if locator.startswith("client:"):
|
|
568
|
+
return inventory_client(locator.split(":", 1)[1], home)
|
|
569
|
+
if locator.startswith("project:"):
|
|
570
|
+
return inventory_project(Path(locator.split(":", 1)[1]))
|
|
571
|
+
if locator.startswith("plugin:"):
|
|
572
|
+
path = Path(locator.split(":", 1)[1]).expanduser().resolve()
|
|
573
|
+
return _inventory_plugin(path, locator)
|
|
574
|
+
path = Path(locator).expanduser().resolve()
|
|
575
|
+
if (path / PROJECT_PROFILE_RELATIVE).is_file() or (path / "AGENTS.md").is_file():
|
|
576
|
+
return inventory_project(path)
|
|
577
|
+
if (path / "plugin.json").is_file():
|
|
578
|
+
return _inventory_plugin(path, f"plugin:{path}")
|
|
579
|
+
raise AgentPackError(f"cannot classify source locator: {locator}")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _add_duplicate_issues(inventory: Inventory) -> None:
|
|
583
|
+
for label, values in (
|
|
584
|
+
("skill", [item.name for item in inventory.skills]),
|
|
585
|
+
("MCP", [item.name for item in inventory.mcps]),
|
|
586
|
+
):
|
|
587
|
+
seen: set[str] = set()
|
|
588
|
+
duplicates: set[str] = set()
|
|
589
|
+
for value in values:
|
|
590
|
+
if value in seen:
|
|
591
|
+
duplicates.add(value)
|
|
592
|
+
seen.add(value)
|
|
593
|
+
for value in sorted(duplicates):
|
|
594
|
+
inventory.issues.append(f"duplicate {label} name: {value}")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _copy_skill(source: Path, destination: Path, *, allowed_root: Path | None = None) -> None:
|
|
598
|
+
source = source.resolve()
|
|
599
|
+
boundary = (allowed_root or source).resolve()
|
|
600
|
+
for base, dirs, files in os.walk(source, followlinks=False):
|
|
601
|
+
dirs[:] = [name for name in dirs if name not in COPY_IGNORE]
|
|
602
|
+
base_path = Path(base)
|
|
603
|
+
for name in [*dirs, *files]:
|
|
604
|
+
candidate = base_path / name
|
|
605
|
+
if candidate.is_symlink():
|
|
606
|
+
resolved = candidate.resolve()
|
|
607
|
+
try:
|
|
608
|
+
resolved.relative_to(boundary)
|
|
609
|
+
except ValueError as exc:
|
|
610
|
+
raise AgentPackError(f"skill symlink escapes allowed source root: {candidate}") from exc
|
|
611
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
612
|
+
temp = Path(tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent))
|
|
613
|
+
try:
|
|
614
|
+
shutil.copytree(
|
|
615
|
+
source,
|
|
616
|
+
temp,
|
|
617
|
+
dirs_exist_ok=True,
|
|
618
|
+
symlinks=False,
|
|
619
|
+
ignore=lambda _base, names: {name for name in names if name in COPY_IGNORE},
|
|
620
|
+
)
|
|
621
|
+
if destination.exists() or destination.is_symlink():
|
|
622
|
+
raise AgentPackError(f"destination already exists: {destination}")
|
|
623
|
+
os.replace(temp, destination)
|
|
624
|
+
finally:
|
|
625
|
+
if temp.exists():
|
|
626
|
+
shutil.rmtree(temp)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _load_destination_mcp(path: Path) -> dict[str, Any]:
|
|
630
|
+
if not path.exists():
|
|
631
|
+
return {"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": {}}
|
|
632
|
+
payload = _read_jsonc(path) if path.suffix == ".jsonc" else _read_json(path)
|
|
633
|
+
servers = payload.get("mcpServers")
|
|
634
|
+
if not isinstance(servers, dict):
|
|
635
|
+
raise AgentPackError(f"destination mcpServers must be an object: {path}")
|
|
636
|
+
if path.suffix != ".jsonc":
|
|
637
|
+
payload["$schema"] = AGENT_PLUGIN_MCP_SCHEMA
|
|
638
|
+
return payload
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def transfer_to_project(
|
|
642
|
+
source: Inventory,
|
|
643
|
+
destination_project: Path,
|
|
644
|
+
skill_names: Iterable[str],
|
|
645
|
+
mcp_names: Iterable[str],
|
|
646
|
+
*,
|
|
647
|
+
apply: bool = False,
|
|
648
|
+
) -> dict[str, Any]:
|
|
649
|
+
layout = load_project_layout(destination_project)
|
|
650
|
+
requested_skills = set(skill_names)
|
|
651
|
+
requested_mcps = set(mcp_names)
|
|
652
|
+
selected_skills = [item for item in source.skills if item.name in requested_skills]
|
|
653
|
+
selected_mcps = [item for item in source.mcps if item.name in requested_mcps]
|
|
654
|
+
missing_skills = requested_skills - {item.name for item in selected_skills}
|
|
655
|
+
missing_mcps = requested_mcps - {item.name for item in selected_mcps}
|
|
656
|
+
if missing_skills or missing_mcps:
|
|
657
|
+
raise AgentPackError(
|
|
658
|
+
"requested items missing: "
|
|
659
|
+
+ ", ".join(sorted(missing_skills | missing_mcps))
|
|
660
|
+
)
|
|
661
|
+
blocked = [
|
|
662
|
+
f"skill:{item.name}" for item in selected_skills if not item.portable
|
|
663
|
+
] + [
|
|
664
|
+
f"mcp:{item.name}" for item in selected_mcps if not item.portable
|
|
665
|
+
]
|
|
666
|
+
if blocked:
|
|
667
|
+
raise AgentPackError("non-portable or secret-bearing items blocked: " + ", ".join(blocked))
|
|
668
|
+
|
|
669
|
+
operations: list[dict[str, str]] = []
|
|
670
|
+
for item in selected_skills:
|
|
671
|
+
destination = layout.destination_skill_root / item.name
|
|
672
|
+
if destination.exists() or destination.is_symlink():
|
|
673
|
+
raise AgentPackError(f"destination already exists: {destination}")
|
|
674
|
+
operations.append({"action": "copy-skill", "name": item.name, "to": str(destination)})
|
|
675
|
+
|
|
676
|
+
mcp_payload = _load_destination_mcp(layout.destination_mcp)
|
|
677
|
+
servers = mcp_payload.setdefault("mcpServers", {})
|
|
678
|
+
for item in selected_mcps:
|
|
679
|
+
if item.name in servers:
|
|
680
|
+
raise AgentPackError(f"MCP destination already exists: {item.name}")
|
|
681
|
+
operations.append({"action": "merge-mcp", "name": item.name, "to": str(layout.destination_mcp)})
|
|
682
|
+
|
|
683
|
+
if apply:
|
|
684
|
+
for item in selected_skills:
|
|
685
|
+
_copy_skill(Path(item.path), layout.destination_skill_root / item.name)
|
|
686
|
+
for item in selected_mcps:
|
|
687
|
+
servers[item.name] = item.config
|
|
688
|
+
if selected_mcps:
|
|
689
|
+
if layout.destination_mcp.suffix == ".jsonc":
|
|
690
|
+
raise AgentPackError("writing legacy JSONC destinations is intentionally unsupported")
|
|
691
|
+
_write_json_atomic(layout.destination_mcp, mcp_payload)
|
|
692
|
+
return {"ok": True, "applied": apply, "operations": operations}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _mcp_allowed(layout: ProjectLayout, name: str, client: str) -> bool:
|
|
696
|
+
policy = layout.mcp_client_policy.get(name, {})
|
|
697
|
+
include = policy.get("include", ())
|
|
698
|
+
exclude = policy.get("exclude", ())
|
|
699
|
+
return (not include or client in include) and client not in exclude
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _env_references(value: Any) -> set[str]:
|
|
703
|
+
if isinstance(value, str):
|
|
704
|
+
return set(ENV_REFERENCE_RE.findall(value)) - RESERVED_ENV_PLACEHOLDERS
|
|
705
|
+
if isinstance(value, list):
|
|
706
|
+
result: set[str] = set()
|
|
707
|
+
for item in value:
|
|
708
|
+
result.update(_env_references(item))
|
|
709
|
+
return result
|
|
710
|
+
if isinstance(value, dict):
|
|
711
|
+
result: set[str] = set()
|
|
712
|
+
for item in value.values():
|
|
713
|
+
result.update(_env_references(item))
|
|
714
|
+
return result
|
|
715
|
+
return set()
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _dotenv_keys(path: Path) -> set[str]:
|
|
719
|
+
if path.is_symlink() or not path.is_file():
|
|
720
|
+
raise AgentPackError(f"credential env file missing or unsafe: {path}")
|
|
721
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
722
|
+
if mode & 0o077:
|
|
723
|
+
raise AgentPackError(f"credential env file must be mode 0600 or stricter: {path}")
|
|
724
|
+
keys: set[str] = set()
|
|
725
|
+
try:
|
|
726
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
727
|
+
line = raw_line.strip()
|
|
728
|
+
if not line or line.startswith("#"):
|
|
729
|
+
continue
|
|
730
|
+
if line.startswith("export "):
|
|
731
|
+
line = line[7:].lstrip()
|
|
732
|
+
key, separator, _value = line.partition("=")
|
|
733
|
+
key = key.strip()
|
|
734
|
+
if separator and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
735
|
+
keys.add(key)
|
|
736
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
737
|
+
raise AgentPackError(f"cannot inspect credential env file {path}: {exc}") from exc
|
|
738
|
+
return keys
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _portable_package_from_inventory(
|
|
742
|
+
inventory: Inventory,
|
|
743
|
+
package_name: str,
|
|
744
|
+
destination: Path,
|
|
745
|
+
*,
|
|
746
|
+
client: str | None = None,
|
|
747
|
+
) -> None:
|
|
748
|
+
invalid = [item.name for item in inventory.skills if not item.portable]
|
|
749
|
+
invalid += [
|
|
750
|
+
item.name for item in inventory.mcps
|
|
751
|
+
if item.authority == "portable" and not item.portable
|
|
752
|
+
]
|
|
753
|
+
if invalid:
|
|
754
|
+
raise AgentPackError("cannot package non-portable items: " + ", ".join(sorted(invalid)))
|
|
755
|
+
project_root: Path | None = None
|
|
756
|
+
layout: ProjectLayout | None = None
|
|
757
|
+
if inventory.source.startswith("project:"):
|
|
758
|
+
project_root = Path(inventory.source.split(":", 1)[1])
|
|
759
|
+
layout = load_project_layout(project_root)
|
|
760
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
761
|
+
_write_json_atomic(destination / "plugin.json", {
|
|
762
|
+
"$schema": AGENT_PLUGIN_SCHEMA,
|
|
763
|
+
"name": package_name,
|
|
764
|
+
"version": "1.0.0",
|
|
765
|
+
"description": f"Project capabilities exported by agent-pack ({package_name}).",
|
|
766
|
+
"keywords": ["agent-pack", "agent-skills", "mcp"],
|
|
767
|
+
})
|
|
768
|
+
skills_root = destination / "skills"
|
|
769
|
+
skills_root.mkdir()
|
|
770
|
+
for item in inventory.skills:
|
|
771
|
+
_copy_skill(Path(item.path), skills_root / item.name, allowed_root=project_root)
|
|
772
|
+
|
|
773
|
+
def project_mcp_config(item: McpRecord) -> dict[str, Any]:
|
|
774
|
+
source_path = Path(item.source)
|
|
775
|
+
|
|
776
|
+
def expand(value: Any) -> Any:
|
|
777
|
+
if isinstance(value, str):
|
|
778
|
+
if "__PROJECT_DIR__" in value:
|
|
779
|
+
if not source_path.is_file():
|
|
780
|
+
raise AgentPackError(f"cannot resolve __PROJECT_DIR__ for MCP {item.name}")
|
|
781
|
+
value = value.replace("__PROJECT_DIR__", str(source_path.parent.resolve()))
|
|
782
|
+
if "__REPO_ROOT__" in value:
|
|
783
|
+
if project_root is None:
|
|
784
|
+
raise AgentPackError(f"cannot resolve __REPO_ROOT__ for MCP {item.name}")
|
|
785
|
+
value = value.replace("__REPO_ROOT__", str(project_root.resolve()))
|
|
786
|
+
return value
|
|
787
|
+
if isinstance(value, list):
|
|
788
|
+
return [expand(child) for child in value]
|
|
789
|
+
if isinstance(value, dict):
|
|
790
|
+
return {key: expand(child) for key, child in value.items()}
|
|
791
|
+
return value
|
|
792
|
+
|
|
793
|
+
return expand(item.config)
|
|
794
|
+
|
|
795
|
+
selected_mcps = [
|
|
796
|
+
item for item in inventory.mcps
|
|
797
|
+
if item.portable and (client is None or layout is None or _mcp_allowed(layout, item.name, client))
|
|
798
|
+
]
|
|
799
|
+
required_env: set[str] = set()
|
|
800
|
+
for item in selected_mcps:
|
|
801
|
+
required_env.update(_env_references(item.config))
|
|
802
|
+
if required_env:
|
|
803
|
+
if layout is None or layout.credential_env_file is None:
|
|
804
|
+
raise AgentPackError(
|
|
805
|
+
"MCP environment references require capabilities.credential_env_file: "
|
|
806
|
+
+ ", ".join(sorted(required_env))
|
|
807
|
+
)
|
|
808
|
+
missing = required_env - _dotenv_keys(layout.credential_env_file)
|
|
809
|
+
if missing:
|
|
810
|
+
raise AgentPackError("credential env file is missing keys: " + ", ".join(sorted(missing)))
|
|
811
|
+
|
|
812
|
+
rendered_mcps: dict[str, dict[str, Any]] = {}
|
|
813
|
+
launcher_copied = False
|
|
814
|
+
for item in selected_mcps:
|
|
815
|
+
config = project_mcp_config(item)
|
|
816
|
+
references = _env_references(config)
|
|
817
|
+
if not references:
|
|
818
|
+
rendered_mcps[item.name] = config
|
|
819
|
+
continue
|
|
820
|
+
if config.get("type") != "stdio":
|
|
821
|
+
raise AgentPackError(f"environment-referenced remote MCP is unsupported: {item.name}")
|
|
822
|
+
runtime_root = destination / "runtime"
|
|
823
|
+
if not launcher_copied:
|
|
824
|
+
runtime_root.mkdir(exist_ok=True)
|
|
825
|
+
shutil.copy2(Path(__file__).with_name("mcp_env_launcher.py"), runtime_root / "mcp_env_launcher.py")
|
|
826
|
+
launcher_copied = True
|
|
827
|
+
config_name = hashlib.sha256(item.name.encode("utf-8")).hexdigest()[:16] + ".json"
|
|
828
|
+
_write_json_atomic(runtime_root / config_name, config)
|
|
829
|
+
assert layout is not None and layout.credential_env_file is not None
|
|
830
|
+
rendered_mcps[item.name] = {
|
|
831
|
+
"type": "stdio",
|
|
832
|
+
"command": "python3",
|
|
833
|
+
"args": [
|
|
834
|
+
"${PLUGIN_ROOT}/runtime/mcp_env_launcher.py",
|
|
835
|
+
"--env-file",
|
|
836
|
+
str(layout.credential_env_file.resolve()),
|
|
837
|
+
"--config",
|
|
838
|
+
f"${{PLUGIN_ROOT}}/runtime/{config_name}",
|
|
839
|
+
],
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
_write_json_atomic(destination / "mcp.json", {
|
|
843
|
+
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
|
844
|
+
"mcpServers": rendered_mcps,
|
|
845
|
+
})
|
|
846
|
+
manifest = _read_json(destination / "plugin.json")
|
|
847
|
+
content_fingerprint = _tree_fingerprint(destination)
|
|
848
|
+
manifest["version"] = f"1.0.0+project.{content_fingerprint[:12]}"
|
|
849
|
+
_write_json_atomic(destination / "plugin.json", manifest)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _codex_adapter(package_root: Path) -> None:
|
|
853
|
+
manifest = _read_json(package_root / "plugin.json")
|
|
854
|
+
codex_dir = package_root / ".codex-plugin"
|
|
855
|
+
codex_dir.mkdir(exist_ok=True)
|
|
856
|
+
codex_manifest_path = codex_dir / "plugin.json"
|
|
857
|
+
if codex_manifest_path.exists():
|
|
858
|
+
codex_manifest = _read_json(codex_manifest_path)
|
|
859
|
+
else:
|
|
860
|
+
codex_manifest = {}
|
|
861
|
+
# Identity fields are owned by the portable plugin.json and are always
|
|
862
|
+
# regenerated here so the Codex adapter can never drift from the
|
|
863
|
+
# authoritative manifest (only Codex-specific presentation such as the
|
|
864
|
+
# `interface` block is preserved as authored).
|
|
865
|
+
codex_manifest["name"] = manifest["name"]
|
|
866
|
+
codex_manifest["version"] = manifest.get("version", "1.0.0")
|
|
867
|
+
codex_manifest.setdefault(
|
|
868
|
+
"description", manifest.get("description", "Project capabilities")
|
|
869
|
+
)
|
|
870
|
+
codex_manifest.setdefault("skills", "./skills/")
|
|
871
|
+
mcp_path = package_root / "mcp.json"
|
|
872
|
+
if not mcp_path.exists():
|
|
873
|
+
_write_json_atomic(codex_manifest_path, codex_manifest)
|
|
874
|
+
return
|
|
875
|
+
codex_manifest.setdefault("mcpServers", "./.mcp.json")
|
|
876
|
+
_write_json_atomic(codex_manifest_path, codex_manifest)
|
|
877
|
+
mcp = _read_json(mcp_path)
|
|
878
|
+
codex_mcps: dict[str, Any] = {}
|
|
879
|
+
for name, config in mcp.get("mcpServers", {}).items():
|
|
880
|
+
# Codex reads the portable mcp.json (${PLUGIN_ROOT}); this projection is
|
|
881
|
+
# what Claude Code and Cursor load, and they only expand CLAUDE_* names.
|
|
882
|
+
item = _claude_plugin_placeholders(dict(config))
|
|
883
|
+
mcp_type = item.pop("type", None)
|
|
884
|
+
if mcp_type == "streamable-http":
|
|
885
|
+
item["type"] = "http"
|
|
886
|
+
elif mcp_type == "sse":
|
|
887
|
+
item["type"] = "sse"
|
|
888
|
+
codex_mcps[name] = item
|
|
889
|
+
_write_json_atomic(package_root / ".mcp.json", {"mcpServers": codex_mcps})
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _claude_plugin_placeholders(value: Any) -> Any:
|
|
893
|
+
if isinstance(value, str):
|
|
894
|
+
return value.replace("${PLUGIN_ROOT}", "${CLAUDE_PLUGIN_ROOT}").replace(
|
|
895
|
+
"${PLUGIN_DATA}", "${CLAUDE_PLUGIN_DATA}"
|
|
896
|
+
)
|
|
897
|
+
if isinstance(value, list):
|
|
898
|
+
return [_claude_plugin_placeholders(child) for child in value]
|
|
899
|
+
if isinstance(value, dict):
|
|
900
|
+
return {key: _claude_plugin_placeholders(child) for key, child in value.items()}
|
|
901
|
+
return value
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _package_name(root: Path) -> str:
|
|
905
|
+
name = re.sub(r"[^a-z0-9]+", "-", root.name.lower()).strip("-")
|
|
906
|
+
name = name or "project-capabilities"
|
|
907
|
+
if not PLUGIN_NAME_RE.fullmatch(name):
|
|
908
|
+
raise AgentPackError(f"cannot derive portable package name from {root.name!r}")
|
|
909
|
+
return name
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _client_package_destination(client: str, home: Path, package_name: str) -> Path:
|
|
913
|
+
if client == "cursor":
|
|
914
|
+
return home / ".cursor" / "plugins" / "local" / package_name
|
|
915
|
+
if client == "codex":
|
|
916
|
+
return home / ".agents" / "plugins" / "plugins" / package_name
|
|
917
|
+
if client == "claude":
|
|
918
|
+
return _claude_plugin_records(home) / package_name
|
|
919
|
+
raise AgentPackError(f"sync adapter not implemented for client: {client}")
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _tree_fingerprint(root: Path) -> str:
|
|
923
|
+
"""Return a stable content fingerprint without following symlinks."""
|
|
924
|
+
digest = hashlib.sha256()
|
|
925
|
+
if root.is_symlink() or not root.is_dir():
|
|
926
|
+
raise AgentPackError(f"expected a real directory: {root}")
|
|
927
|
+
for base, dirs, files in os.walk(root, followlinks=False):
|
|
928
|
+
dirs[:] = sorted(name for name in dirs if name not in COPY_IGNORE)
|
|
929
|
+
for name in sorted(files):
|
|
930
|
+
path = Path(base) / name
|
|
931
|
+
relative = path.relative_to(root)
|
|
932
|
+
if path.is_symlink():
|
|
933
|
+
raise AgentPackError(f"package contains symlink: {path}")
|
|
934
|
+
digest.update(str(relative).encode("utf-8"))
|
|
935
|
+
digest.update(b"\0")
|
|
936
|
+
try:
|
|
937
|
+
digest.update(path.read_bytes())
|
|
938
|
+
except OSError as exc:
|
|
939
|
+
raise AgentPackError(f"cannot fingerprint {path}: {exc}") from exc
|
|
940
|
+
digest.update(b"\0")
|
|
941
|
+
return digest.hexdigest()
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _source_matches_destination(source: Path, destination: Path) -> bool:
|
|
945
|
+
if source.is_symlink() or destination.is_symlink() or not destination.is_dir():
|
|
946
|
+
return False
|
|
947
|
+
for base, dirs, files in os.walk(source, followlinks=False):
|
|
948
|
+
dirs[:] = [name for name in dirs if name not in COPY_IGNORE]
|
|
949
|
+
for name in files:
|
|
950
|
+
path = Path(base) / name
|
|
951
|
+
if path.is_symlink():
|
|
952
|
+
return False
|
|
953
|
+
target = destination / path.relative_to(source)
|
|
954
|
+
try:
|
|
955
|
+
if target.is_symlink() or not target.is_file() or target.read_bytes() != path.read_bytes():
|
|
956
|
+
return False
|
|
957
|
+
except OSError:
|
|
958
|
+
return False
|
|
959
|
+
return True
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _source_matches_client_destination(
|
|
963
|
+
source: Path,
|
|
964
|
+
destination: Path,
|
|
965
|
+
client: str,
|
|
966
|
+
) -> bool:
|
|
967
|
+
if client != "codex":
|
|
968
|
+
return _source_matches_destination(source, destination)
|
|
969
|
+
with tempfile.TemporaryDirectory(prefix="agent-pack-match-") as temporary:
|
|
970
|
+
staged = Path(temporary) / source.name
|
|
971
|
+
shutil.copytree(source, staged, symlinks=False, ignore=shutil.ignore_patterns(*COPY_IGNORE))
|
|
972
|
+
_codex_adapter(staged)
|
|
973
|
+
return _source_matches_destination(staged, destination)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _build_package(inventory: Inventory, package_name: str, parent: Path, client: str) -> Path:
|
|
977
|
+
package = parent / package_name
|
|
978
|
+
_portable_package_from_inventory(inventory, package_name, package, client=client)
|
|
979
|
+
if client == "codex":
|
|
980
|
+
_codex_adapter(package)
|
|
981
|
+
return package
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def install_plugin(
|
|
985
|
+
plugin: Path,
|
|
986
|
+
client: str,
|
|
987
|
+
*,
|
|
988
|
+
home: Path | None = None,
|
|
989
|
+
apply: bool = False,
|
|
990
|
+
replace: bool = False,
|
|
991
|
+
recovery_root: Path | None = None,
|
|
992
|
+
) -> dict[str, Any]:
|
|
993
|
+
"""Bootstrap a portable plugin into a supported client without overwriting."""
|
|
994
|
+
plugin = plugin.expanduser().resolve()
|
|
995
|
+
manifest, issues = validate_plugin_manifest(plugin)
|
|
996
|
+
if issues:
|
|
997
|
+
raise AgentPackError("invalid plugin: " + "; ".join(issues))
|
|
998
|
+
name = manifest["name"]
|
|
999
|
+
_tree_fingerprint(plugin)
|
|
1000
|
+
home = (home or Path.home()).expanduser().resolve()
|
|
1001
|
+
if client == "claude":
|
|
1002
|
+
return _install_plugin_claude(
|
|
1003
|
+
plugin, name, home=home, apply=apply, replace=replace, recovery_root=recovery_root
|
|
1004
|
+
)
|
|
1005
|
+
destination = _client_package_destination(client, home, name)
|
|
1006
|
+
action = "install-plugin"
|
|
1007
|
+
if destination.exists() or destination.is_symlink():
|
|
1008
|
+
if not _source_matches_client_destination(plugin, destination, client):
|
|
1009
|
+
if not replace:
|
|
1010
|
+
raise AgentPackError(f"client package already exists with different content: {destination}")
|
|
1011
|
+
action = "replace-plugin"
|
|
1012
|
+
else:
|
|
1013
|
+
action = "keep-plugin"
|
|
1014
|
+
operation = {"action": action, "client": client, "name": name, "to": str(destination)}
|
|
1015
|
+
if apply:
|
|
1016
|
+
archived: Path | None = None
|
|
1017
|
+
if action in {"install-plugin", "replace-plugin"}:
|
|
1018
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1019
|
+
if action == "replace-plugin":
|
|
1020
|
+
recovery_base = (recovery_root or home / ".agent-pack" / "recovery").expanduser().resolve()
|
|
1021
|
+
recovery_base.mkdir(parents=True, exist_ok=True)
|
|
1022
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
1023
|
+
archived = recovery_base / f"{stamp}-{client}-{name}" / "replaced-plugin" / name
|
|
1024
|
+
archived.parent.mkdir(parents=True, exist_ok=True)
|
|
1025
|
+
os.replace(destination, archived)
|
|
1026
|
+
temp_parent = Path(tempfile.mkdtemp(prefix=".agent-pack-bootstrap-", dir=destination.parent))
|
|
1027
|
+
staged = temp_parent / name
|
|
1028
|
+
try:
|
|
1029
|
+
shutil.copytree(plugin, staged, symlinks=False, ignore=shutil.ignore_patterns(*COPY_IGNORE))
|
|
1030
|
+
if client == "codex":
|
|
1031
|
+
_codex_adapter(staged)
|
|
1032
|
+
os.replace(staged, destination)
|
|
1033
|
+
except Exception:
|
|
1034
|
+
if archived is not None and archived.exists() and not destination.exists():
|
|
1035
|
+
os.replace(archived, destination)
|
|
1036
|
+
raise
|
|
1037
|
+
finally:
|
|
1038
|
+
if temp_parent.exists():
|
|
1039
|
+
shutil.rmtree(temp_parent)
|
|
1040
|
+
if client == "codex":
|
|
1041
|
+
_update_personal_marketplace(home, name)
|
|
1042
|
+
return {"ok": True, "applied": apply, "operations": [operation]}
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _claude_mcp_config(config: dict[str, Any], plugin_root: Path, data_root: Path) -> dict[str, Any]:
|
|
1046
|
+
"""Project a portable MCP entry onto Claude Code's ``mcpServers`` schema."""
|
|
1047
|
+
|
|
1048
|
+
def expand(value: Any) -> Any:
|
|
1049
|
+
if isinstance(value, str):
|
|
1050
|
+
return value.replace("${PLUGIN_ROOT}", str(plugin_root)).replace(
|
|
1051
|
+
"${PLUGIN_DATA}", str(data_root)
|
|
1052
|
+
)
|
|
1053
|
+
if isinstance(value, list):
|
|
1054
|
+
return [expand(child) for child in value]
|
|
1055
|
+
if isinstance(value, dict):
|
|
1056
|
+
return {key: expand(child) for key, child in value.items()}
|
|
1057
|
+
return value
|
|
1058
|
+
|
|
1059
|
+
item = expand(dict(config))
|
|
1060
|
+
# Claude Code stdio entries have no working-directory field; plugin MCPs
|
|
1061
|
+
# must address their files through ${PLUGIN_ROOT} instead.
|
|
1062
|
+
item.pop("cwd", None)
|
|
1063
|
+
if item.get("type") == "streamable-http":
|
|
1064
|
+
item["type"] = "http"
|
|
1065
|
+
return item
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _install_plugin_claude(
|
|
1069
|
+
plugin: Path,
|
|
1070
|
+
name: str,
|
|
1071
|
+
*,
|
|
1072
|
+
home: Path,
|
|
1073
|
+
apply: bool,
|
|
1074
|
+
replace: bool,
|
|
1075
|
+
recovery_root: Path | None,
|
|
1076
|
+
) -> dict[str, Any]:
|
|
1077
|
+
"""Install a plugin into Claude Code as live links plus user-scope MCP entries.
|
|
1078
|
+
|
|
1079
|
+
Claude Code has no Agent Plugins loader, so the plugin is projected onto the
|
|
1080
|
+
three things it does read: ``~/.claude/skills/<skill>`` (symlink to the
|
|
1081
|
+
source skill), ``~/.claude.json#mcpServers`` (user scope), and an install
|
|
1082
|
+
record ``~/.claude/agent-pack/plugins/<name>`` (symlink to the source root)
|
|
1083
|
+
that inventory uses to report the installed version.
|
|
1084
|
+
"""
|
|
1085
|
+
record = _claude_plugin_records(home) / name
|
|
1086
|
+
links: list[tuple[str, str, Path, Path]] = [("plugin", name, record, plugin)]
|
|
1087
|
+
skills_root = plugin / "skills"
|
|
1088
|
+
if skills_root.is_dir() and not skills_root.is_symlink():
|
|
1089
|
+
for child in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
|
1090
|
+
if child.is_dir() and not child.name.startswith(".") and (child / "SKILL.md").is_file():
|
|
1091
|
+
links.append(("skill", child.name, home / ".claude" / "skills" / child.name, child))
|
|
1092
|
+
|
|
1093
|
+
config_path = _claude_user_config(home)
|
|
1094
|
+
servers: dict[str, dict[str, Any]] = {}
|
|
1095
|
+
mcp_path = plugin / "mcp.json"
|
|
1096
|
+
if mcp_path.is_file():
|
|
1097
|
+
data_root = home / ".claude" / "agent-pack" / "data" / name
|
|
1098
|
+
for server, raw in sorted(_read_json(mcp_path).get("mcpServers", {}).items()):
|
|
1099
|
+
parsed = _portable_mcp(server, raw)
|
|
1100
|
+
if not parsed.portable:
|
|
1101
|
+
raise AgentPackError(f"MCP {server} is not portable: " + "; ".join(parsed.issues))
|
|
1102
|
+
servers[server] = _claude_mcp_config(parsed.config, record, data_root)
|
|
1103
|
+
|
|
1104
|
+
config: dict[str, Any] = {}
|
|
1105
|
+
if servers and config_path.exists():
|
|
1106
|
+
if config_path.is_symlink():
|
|
1107
|
+
raise AgentPackError(f"refusing to edit symlinked Claude config: {config_path}")
|
|
1108
|
+
config = _read_json(config_path)
|
|
1109
|
+
existing_servers = config.get("mcpServers", {})
|
|
1110
|
+
if not isinstance(existing_servers, dict):
|
|
1111
|
+
raise AgentPackError(f"mcpServers must be an object: {config_path}")
|
|
1112
|
+
|
|
1113
|
+
operations: list[dict[str, str]] = []
|
|
1114
|
+
conflicts: list[str] = []
|
|
1115
|
+
for kind, item_name, path, target in links:
|
|
1116
|
+
if path.is_symlink() and path.resolve() == target.resolve():
|
|
1117
|
+
action = "keep"
|
|
1118
|
+
elif path.exists() or path.is_symlink():
|
|
1119
|
+
action = "replace"
|
|
1120
|
+
conflicts.append(f"{kind} {item_name}: {path}")
|
|
1121
|
+
else:
|
|
1122
|
+
action = "link"
|
|
1123
|
+
operations.append({"action": f"{action}-{kind}", "client": "claude", "name": item_name, "to": str(path)})
|
|
1124
|
+
for server, rendered in servers.items():
|
|
1125
|
+
current = existing_servers.get(server)
|
|
1126
|
+
if current is None:
|
|
1127
|
+
action = "add"
|
|
1128
|
+
elif current == rendered:
|
|
1129
|
+
action = "keep"
|
|
1130
|
+
else:
|
|
1131
|
+
action = "replace"
|
|
1132
|
+
conflicts.append(f"mcp {server}: {config_path}")
|
|
1133
|
+
operations.append({"action": f"{action}-mcp", "client": "claude", "name": server, "to": str(config_path)})
|
|
1134
|
+
if conflicts and not replace:
|
|
1135
|
+
raise AgentPackError("Claude client already has different content: " + "; ".join(conflicts))
|
|
1136
|
+
result: dict[str, Any] = {"ok": True, "applied": apply, "operations": operations}
|
|
1137
|
+
if not apply:
|
|
1138
|
+
return result
|
|
1139
|
+
|
|
1140
|
+
transaction: Path | None = None
|
|
1141
|
+
if conflicts:
|
|
1142
|
+
recovery_base = (recovery_root or home / ".agent-pack" / "recovery").expanduser().resolve()
|
|
1143
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
1144
|
+
transaction = recovery_base / f"{stamp}-claude-{name}"
|
|
1145
|
+
transaction.mkdir(parents=True, mode=0o700)
|
|
1146
|
+
result["recovery"] = str(transaction)
|
|
1147
|
+
moved: list[tuple[Path, Path]] = []
|
|
1148
|
+
created: list[Path] = []
|
|
1149
|
+
original_config = json.loads(json.dumps(config))
|
|
1150
|
+
config_written = False
|
|
1151
|
+
try:
|
|
1152
|
+
for (kind, item_name, path, target), operation in zip(links, operations):
|
|
1153
|
+
if operation["action"].startswith("keep-"):
|
|
1154
|
+
continue
|
|
1155
|
+
if operation["action"].startswith("replace-"):
|
|
1156
|
+
assert transaction is not None
|
|
1157
|
+
archived = transaction / f"replaced-{kind}s" / item_name
|
|
1158
|
+
archived.parent.mkdir(parents=True, exist_ok=True)
|
|
1159
|
+
os.replace(path, archived)
|
|
1160
|
+
moved.append((archived, path))
|
|
1161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1162
|
+
os.symlink(target, path, target_is_directory=True)
|
|
1163
|
+
created.append(path)
|
|
1164
|
+
changed = {
|
|
1165
|
+
server: rendered for server, rendered in servers.items()
|
|
1166
|
+
if existing_servers.get(server) != rendered
|
|
1167
|
+
}
|
|
1168
|
+
if changed:
|
|
1169
|
+
replaced = {server: existing_servers[server] for server in changed if server in existing_servers}
|
|
1170
|
+
if replaced:
|
|
1171
|
+
assert transaction is not None
|
|
1172
|
+
_write_json_atomic(transaction / "replaced-mcp.json", {"mcpServers": replaced}, mode=0o600)
|
|
1173
|
+
config.setdefault("mcpServers", {}).update(changed)
|
|
1174
|
+
mode = stat.S_IMODE(config_path.stat().st_mode) if config_path.exists() else 0o600
|
|
1175
|
+
_write_json_atomic(config_path, config, mode=mode)
|
|
1176
|
+
config_written = True
|
|
1177
|
+
except Exception as exc:
|
|
1178
|
+
if config_written:
|
|
1179
|
+
_write_json_atomic(config_path, original_config, mode=0o600)
|
|
1180
|
+
for path in reversed(created):
|
|
1181
|
+
if path.is_symlink():
|
|
1182
|
+
path.unlink()
|
|
1183
|
+
for archived, original in reversed(moved):
|
|
1184
|
+
if archived.exists() and not original.exists():
|
|
1185
|
+
os.replace(archived, original)
|
|
1186
|
+
raise AgentPackError(f"claude install rolled back: {exc}") from exc
|
|
1187
|
+
return result
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _component_state(
|
|
1191
|
+
authority: Inventory,
|
|
1192
|
+
client_inventory: Inventory,
|
|
1193
|
+
*,
|
|
1194
|
+
client: str,
|
|
1195
|
+
layout: ProjectLayout,
|
|
1196
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
1197
|
+
states: dict[str, list[dict[str, Any]]] = {
|
|
1198
|
+
"exact": [], "conflict": [], "missing": [], "native_only": [], "blocked": []
|
|
1199
|
+
}
|
|
1200
|
+
expected_skills = {item.name: item for item in authority.skills}
|
|
1201
|
+
actual_skills: dict[str, list[SkillRecord]] = {}
|
|
1202
|
+
for item in client_inventory.skills:
|
|
1203
|
+
actual_skills.setdefault(item.name, []).append(item)
|
|
1204
|
+
for name, expected in sorted(expected_skills.items()):
|
|
1205
|
+
actual = actual_skills.pop(name, [])
|
|
1206
|
+
if not actual:
|
|
1207
|
+
states["missing"].append({"kind": "skill", "name": name})
|
|
1208
|
+
continue
|
|
1209
|
+
try:
|
|
1210
|
+
expected_hash = _tree_fingerprint(Path(expected.path))
|
|
1211
|
+
hashes = [_tree_fingerprint(Path(item.path)) for item in actual]
|
|
1212
|
+
except AgentPackError:
|
|
1213
|
+
states["conflict"].append({"kind": "skill", "name": name, "copies": len(actual)})
|
|
1214
|
+
continue
|
|
1215
|
+
target = "exact" if len(actual) == 1 and hashes[0] == expected_hash else "conflict"
|
|
1216
|
+
states[target].append({"kind": "skill", "name": name, "copies": len(actual)})
|
|
1217
|
+
for name, values in sorted(actual_skills.items()):
|
|
1218
|
+
states["native_only"].append({"kind": "skill", "name": name, "copies": len(values)})
|
|
1219
|
+
|
|
1220
|
+
expected_mcps = {
|
|
1221
|
+
item.name: item for item in authority.mcps
|
|
1222
|
+
if item.portable and _mcp_allowed(layout, item.name, client)
|
|
1223
|
+
}
|
|
1224
|
+
blocked_mcps = {
|
|
1225
|
+
item.name: item for item in authority.mcps
|
|
1226
|
+
if not item.portable and _mcp_allowed(layout, item.name, client)
|
|
1227
|
+
}
|
|
1228
|
+
actual_mcps: dict[str, list[McpRecord]] = {}
|
|
1229
|
+
for item in client_inventory.mcps:
|
|
1230
|
+
actual_mcps.setdefault(item.name, []).append(item)
|
|
1231
|
+
for name, expected in sorted(expected_mcps.items()):
|
|
1232
|
+
actual = actual_mcps.pop(name, [])
|
|
1233
|
+
if not actual:
|
|
1234
|
+
states["missing"].append({"kind": "mcp", "name": name})
|
|
1235
|
+
elif len(actual) == 1 and actual[0].config == expected.config:
|
|
1236
|
+
states["exact"].append({"kind": "mcp", "name": name, "copies": 1})
|
|
1237
|
+
else:
|
|
1238
|
+
states["conflict"].append({"kind": "mcp", "name": name, "copies": len(actual)})
|
|
1239
|
+
for name, item in sorted(blocked_mcps.items()):
|
|
1240
|
+
actual = actual_mcps.pop(name, [])
|
|
1241
|
+
states["blocked"].append({
|
|
1242
|
+
"kind": "mcp", "name": name, "authority": item.authority,
|
|
1243
|
+
"installed": bool(actual), "issues": list(item.issues),
|
|
1244
|
+
})
|
|
1245
|
+
for name, values in sorted(actual_mcps.items()):
|
|
1246
|
+
states["native_only"].append({"kind": "mcp", "name": name, "copies": len(values)})
|
|
1247
|
+
return states
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def reconcile_project(
|
|
1251
|
+
project: Path,
|
|
1252
|
+
client: str,
|
|
1253
|
+
*,
|
|
1254
|
+
home: Path | None = None,
|
|
1255
|
+
retire_skills: Iterable[str] = (),
|
|
1256
|
+
retire_plugins: Iterable[str] = (),
|
|
1257
|
+
recovery_root: Path | None = None,
|
|
1258
|
+
apply: bool = False,
|
|
1259
|
+
retire_only: bool = False,
|
|
1260
|
+
) -> dict[str, Any]:
|
|
1261
|
+
"""Reconcile one project package and explicitly selected legacy components.
|
|
1262
|
+
|
|
1263
|
+
``retire_only`` archives the selected legacy components without installing
|
|
1264
|
+
or replacing the project package itself.
|
|
1265
|
+
"""
|
|
1266
|
+
layout = load_project_layout(project)
|
|
1267
|
+
authority = inventory_project(layout.root)
|
|
1268
|
+
package_name = _package_name(layout.root)
|
|
1269
|
+
home = (home or Path.home()).expanduser().resolve()
|
|
1270
|
+
destination = _client_package_destination(client, home, package_name)
|
|
1271
|
+
client_inventory = inventory_client(client, home)
|
|
1272
|
+
states = _component_state(authority, client_inventory, client=client, layout=layout)
|
|
1273
|
+
|
|
1274
|
+
retirement_targets: list[tuple[str, str, Path]] = []
|
|
1275
|
+
actual_skills = {item.name: Path(item.path) for item in client_inventory.skills}
|
|
1276
|
+
actual_plugins = {
|
|
1277
|
+
str(item.get("name")): Path(str(item.get("path")))
|
|
1278
|
+
for item in client_inventory.plugins if item.get("name") and item.get("path")
|
|
1279
|
+
}
|
|
1280
|
+
for name in sorted(set(retire_skills)):
|
|
1281
|
+
path = actual_skills.get(name)
|
|
1282
|
+
if path is None:
|
|
1283
|
+
raise AgentPackError(f"retire Skill not installed: {name}")
|
|
1284
|
+
if destination.exists():
|
|
1285
|
+
try:
|
|
1286
|
+
path.resolve().relative_to(destination.resolve())
|
|
1287
|
+
except ValueError:
|
|
1288
|
+
pass
|
|
1289
|
+
else:
|
|
1290
|
+
raise AgentPackError(f"cannot retire Skill inside active project package: {name}")
|
|
1291
|
+
retirement_targets.append(("skill", name, path))
|
|
1292
|
+
for name in sorted(set(retire_plugins)):
|
|
1293
|
+
path = actual_plugins.get(name)
|
|
1294
|
+
if path is None:
|
|
1295
|
+
raise AgentPackError(f"retire plugin not installed: {name}")
|
|
1296
|
+
retirement_targets.append(("plugin", name, path))
|
|
1297
|
+
|
|
1298
|
+
if retire_only and not retirement_targets:
|
|
1299
|
+
raise AgentPackError("retire-only reconcile needs at least one --retire-skill or --retire-plugin")
|
|
1300
|
+
package_action = "install-plugin"
|
|
1301
|
+
needs_package = not retire_only
|
|
1302
|
+
if retire_only:
|
|
1303
|
+
package_action = "skip-plugin"
|
|
1304
|
+
elif destination.exists() or destination.is_symlink():
|
|
1305
|
+
with tempfile.TemporaryDirectory(prefix="agent-pack-plan-") as temporary:
|
|
1306
|
+
expected = _build_package(authority, package_name, Path(temporary), client)
|
|
1307
|
+
if _tree_fingerprint(expected) == _tree_fingerprint(destination):
|
|
1308
|
+
package_action = "keep-plugin"
|
|
1309
|
+
needs_package = False
|
|
1310
|
+
else:
|
|
1311
|
+
package_action = "replace-plugin"
|
|
1312
|
+
|
|
1313
|
+
operations: list[dict[str, str]] = [] if retire_only else [
|
|
1314
|
+
{"action": package_action, "client": client, "name": package_name, "to": str(destination)}
|
|
1315
|
+
]
|
|
1316
|
+
operations.extend(
|
|
1317
|
+
{"action": "archive-" + kind, "name": name, "from": str(path)}
|
|
1318
|
+
for kind, name, path in retirement_targets
|
|
1319
|
+
)
|
|
1320
|
+
if not apply:
|
|
1321
|
+
return {"ok": True, "applied": False, "states": states, "operations": operations}
|
|
1322
|
+
|
|
1323
|
+
recovery_base = (recovery_root or home / ".agent-pack" / "recovery").expanduser().resolve()
|
|
1324
|
+
recovery_base.mkdir(parents=True, exist_ok=True)
|
|
1325
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
1326
|
+
transaction = recovery_base / f"{stamp}-{client}-{package_name}"
|
|
1327
|
+
transaction.mkdir(mode=0o700)
|
|
1328
|
+
moved: list[tuple[Path, Path]] = []
|
|
1329
|
+
installed = False
|
|
1330
|
+
try:
|
|
1331
|
+
if needs_package:
|
|
1332
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1333
|
+
if destination.exists() or destination.is_symlink():
|
|
1334
|
+
archived = transaction / "replaced-plugin" / package_name
|
|
1335
|
+
archived.parent.mkdir(parents=True)
|
|
1336
|
+
os.replace(destination, archived)
|
|
1337
|
+
moved.append((archived, destination))
|
|
1338
|
+
staging_parent = Path(tempfile.mkdtemp(prefix=".agent-pack-", dir=destination.parent))
|
|
1339
|
+
try:
|
|
1340
|
+
staged = _build_package(authority, package_name, staging_parent, client)
|
|
1341
|
+
os.replace(staged, destination)
|
|
1342
|
+
installed = True
|
|
1343
|
+
finally:
|
|
1344
|
+
if staging_parent.exists():
|
|
1345
|
+
shutil.rmtree(staging_parent)
|
|
1346
|
+
for kind, name, path in retirement_targets:
|
|
1347
|
+
if path == destination:
|
|
1348
|
+
raise AgentPackError(f"cannot retire active project package: {name}")
|
|
1349
|
+
archived = transaction / (kind + "s") / name
|
|
1350
|
+
archived.parent.mkdir(parents=True, exist_ok=True)
|
|
1351
|
+
os.replace(path, archived)
|
|
1352
|
+
moved.append((archived, path))
|
|
1353
|
+
if client == "codex" and needs_package:
|
|
1354
|
+
_update_personal_marketplace(home, package_name)
|
|
1355
|
+
except Exception as exc:
|
|
1356
|
+
if installed and destination.exists():
|
|
1357
|
+
shutil.rmtree(destination)
|
|
1358
|
+
for archived, original in reversed(moved):
|
|
1359
|
+
if archived.exists() and not original.exists():
|
|
1360
|
+
original.parent.mkdir(parents=True, exist_ok=True)
|
|
1361
|
+
os.replace(archived, original)
|
|
1362
|
+
raise AgentPackError(f"reconcile rolled back: {exc}") from exc
|
|
1363
|
+
return {
|
|
1364
|
+
"ok": True, "applied": True, "states": states, "operations": operations,
|
|
1365
|
+
"recovery": str(transaction),
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def sync_project(
|
|
1370
|
+
project: Path,
|
|
1371
|
+
client: str,
|
|
1372
|
+
*,
|
|
1373
|
+
home: Path | None = None,
|
|
1374
|
+
apply: bool = False,
|
|
1375
|
+
) -> dict[str, Any]:
|
|
1376
|
+
return reconcile_project(project, client, home=home, apply=apply)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _update_personal_marketplace(home: Path, package_name: str) -> None:
|
|
1380
|
+
marketplace = home / ".agents" / "plugins" / "marketplace.json"
|
|
1381
|
+
if marketplace.exists():
|
|
1382
|
+
payload = _read_json(marketplace)
|
|
1383
|
+
else:
|
|
1384
|
+
payload = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
|
|
1385
|
+
plugins = payload.setdefault("plugins", [])
|
|
1386
|
+
if not isinstance(plugins, list):
|
|
1387
|
+
raise AgentPackError(f"marketplace plugins must be an array: {marketplace}")
|
|
1388
|
+
plugins[:] = [item for item in plugins if not isinstance(item, dict) or item.get("name") != package_name]
|
|
1389
|
+
plugins.append({
|
|
1390
|
+
"name": package_name,
|
|
1391
|
+
"source": {"source": "local", "path": f"./.agents/plugins/plugins/{package_name}"},
|
|
1392
|
+
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
|
|
1393
|
+
"category": "Developer Tools",
|
|
1394
|
+
})
|
|
1395
|
+
_write_json_atomic(marketplace, payload)
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def doctor(project: Path) -> dict[str, Any]:
|
|
1399
|
+
layout = load_project_layout(project)
|
|
1400
|
+
inventory = inventory_project(layout.root)
|
|
1401
|
+
issues = list(inventory.issues)
|
|
1402
|
+
warnings: list[str] = []
|
|
1403
|
+
for item in inventory.skills:
|
|
1404
|
+
issues.extend(f"skill {item.name}: {issue}" for issue in item.issues)
|
|
1405
|
+
for item in inventory.mcps:
|
|
1406
|
+
target = warnings if item.authority == "native" else issues
|
|
1407
|
+
target.extend(f"MCP {item.name}: {issue}" for issue in item.issues)
|
|
1408
|
+
configured_names = {item.name for item in inventory.mcps}
|
|
1409
|
+
unknown_policies = set(layout.mcp_client_policy) - configured_names
|
|
1410
|
+
issues.extend(f"MCP client policy references unknown server: {name}" for name in sorted(unknown_policies))
|
|
1411
|
+
for plugin in inventory.plugins:
|
|
1412
|
+
adapter_path = Path(plugin["path"]) / ".codex-plugin" / "plugin.json"
|
|
1413
|
+
if not adapter_path.is_file() or adapter_path.is_symlink():
|
|
1414
|
+
continue
|
|
1415
|
+
try:
|
|
1416
|
+
adapter_version = _read_json(adapter_path).get("version")
|
|
1417
|
+
except AgentPackError as exc:
|
|
1418
|
+
issues.append(str(exc))
|
|
1419
|
+
continue
|
|
1420
|
+
if adapter_version != plugin["version"]:
|
|
1421
|
+
issues.append(
|
|
1422
|
+
f"plugin {plugin['name']}: Codex adapter version {adapter_version!r} "
|
|
1423
|
+
f"does not match plugin.json version {plugin['version']!r}"
|
|
1424
|
+
)
|
|
1425
|
+
required_env: set[str] = set()
|
|
1426
|
+
for item in inventory.mcps:
|
|
1427
|
+
if item.portable:
|
|
1428
|
+
required_env.update(_env_references(item.config))
|
|
1429
|
+
if required_env:
|
|
1430
|
+
if layout.credential_env_file is None:
|
|
1431
|
+
issues.append("MCP environment references require capabilities.credential_env_file")
|
|
1432
|
+
else:
|
|
1433
|
+
try:
|
|
1434
|
+
missing = required_env - _dotenv_keys(layout.credential_env_file)
|
|
1435
|
+
issues.extend(f"credential env file missing key: {name}" for name in sorted(missing))
|
|
1436
|
+
except AgentPackError as exc:
|
|
1437
|
+
issues.append(str(exc))
|
|
1438
|
+
return {
|
|
1439
|
+
"ok": not issues,
|
|
1440
|
+
"source": inventory.source,
|
|
1441
|
+
"counts": {
|
|
1442
|
+
"plugins": len(inventory.plugins),
|
|
1443
|
+
"skills": len(inventory.skills),
|
|
1444
|
+
"mcps": len(inventory.mcps),
|
|
1445
|
+
},
|
|
1446
|
+
"issues": sorted(set(issues)),
|
|
1447
|
+
"warnings": sorted(set(warnings)),
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _print(value: dict[str, Any], output: str) -> None:
|
|
1452
|
+
if output == "json":
|
|
1453
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
1454
|
+
return
|
|
1455
|
+
print(f"ok: {str(value.get('ok', True)).lower()}")
|
|
1456
|
+
if "applied" in value:
|
|
1457
|
+
print(f"applied: {str(value['applied']).lower()}")
|
|
1458
|
+
counts = value.get("counts")
|
|
1459
|
+
if isinstance(counts, dict):
|
|
1460
|
+
print("counts: " + ", ".join(f"{key}={number}" for key, number in counts.items()))
|
|
1461
|
+
for operation in value.get("operations", []):
|
|
1462
|
+
target = operation.get("to") or operation.get("from", "")
|
|
1463
|
+
print(f"- {operation['action']}: {operation.get('name', '')} -> {target}")
|
|
1464
|
+
for issue in value.get("issues", []):
|
|
1465
|
+
print(f"! {issue}")
|
|
1466
|
+
for warning in value.get("warnings", []):
|
|
1467
|
+
print(f"~ {warning}")
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1471
|
+
parser = argparse.ArgumentParser(prog="agent-pack")
|
|
1472
|
+
parser.add_argument("--output", choices=("text", "json"), default="text")
|
|
1473
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1474
|
+
|
|
1475
|
+
inventory_parser = sub.add_parser("inventory")
|
|
1476
|
+
inventory_parser.add_argument("--source", required=True)
|
|
1477
|
+
inventory_parser.add_argument("--home", type=Path)
|
|
1478
|
+
|
|
1479
|
+
doctor_parser = sub.add_parser("doctor")
|
|
1480
|
+
doctor_parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
1481
|
+
|
|
1482
|
+
transfer_parser = sub.add_parser("transfer")
|
|
1483
|
+
transfer_parser.add_argument("--from", dest="source", required=True)
|
|
1484
|
+
transfer_parser.add_argument("--to-project", type=Path, required=True)
|
|
1485
|
+
transfer_parser.add_argument("--skill", action="append", default=[])
|
|
1486
|
+
transfer_parser.add_argument("--mcp", action="append", default=[])
|
|
1487
|
+
transfer_parser.add_argument("--home", type=Path)
|
|
1488
|
+
transfer_parser.add_argument("--apply", action="store_true")
|
|
1489
|
+
|
|
1490
|
+
sync_parser = sub.add_parser("sync")
|
|
1491
|
+
sync_parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
1492
|
+
sync_parser.add_argument("--client", choices=("codex", "cursor"), required=True)
|
|
1493
|
+
sync_parser.add_argument("--home", type=Path)
|
|
1494
|
+
sync_parser.add_argument("--apply", action="store_true")
|
|
1495
|
+
|
|
1496
|
+
bootstrap_parser = sub.add_parser("bootstrap")
|
|
1497
|
+
bootstrap_parser.add_argument("--plugin", type=Path, required=True)
|
|
1498
|
+
bootstrap_parser.add_argument("--client", choices=("codex", "cursor", "claude"), required=True)
|
|
1499
|
+
bootstrap_parser.add_argument("--home", type=Path)
|
|
1500
|
+
bootstrap_parser.add_argument("--apply", action="store_true")
|
|
1501
|
+
bootstrap_parser.add_argument("--replace", action="store_true")
|
|
1502
|
+
bootstrap_parser.add_argument("--recovery-root", type=Path)
|
|
1503
|
+
|
|
1504
|
+
reconcile_parser = sub.add_parser("reconcile")
|
|
1505
|
+
reconcile_parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
1506
|
+
reconcile_parser.add_argument("--client", choices=("codex", "cursor"), required=True)
|
|
1507
|
+
reconcile_parser.add_argument("--home", type=Path)
|
|
1508
|
+
reconcile_parser.add_argument("--retire-skill", action="append", default=[])
|
|
1509
|
+
reconcile_parser.add_argument("--retire-plugin", action="append", default=[])
|
|
1510
|
+
reconcile_parser.add_argument("--recovery-root", type=Path)
|
|
1511
|
+
reconcile_parser.add_argument(
|
|
1512
|
+
"--retire-only", action="store_true",
|
|
1513
|
+
help="archive the selected legacy components without installing the project package",
|
|
1514
|
+
)
|
|
1515
|
+
reconcile_parser.add_argument("--apply", action="store_true")
|
|
1516
|
+
return parser
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1520
|
+
parser = build_parser()
|
|
1521
|
+
args = parser.parse_args(argv)
|
|
1522
|
+
try:
|
|
1523
|
+
if args.command == "inventory":
|
|
1524
|
+
result = inventory_source(args.source, args.home).to_dict()
|
|
1525
|
+
result["ok"] = not result["issues"]
|
|
1526
|
+
elif args.command == "doctor":
|
|
1527
|
+
result = doctor(args.project)
|
|
1528
|
+
elif args.command == "transfer":
|
|
1529
|
+
if not args.skill and not args.mcp:
|
|
1530
|
+
raise AgentPackError("select at least one --skill or --mcp")
|
|
1531
|
+
result = transfer_to_project(
|
|
1532
|
+
inventory_source(args.source, args.home),
|
|
1533
|
+
args.to_project,
|
|
1534
|
+
args.skill,
|
|
1535
|
+
args.mcp,
|
|
1536
|
+
apply=args.apply,
|
|
1537
|
+
)
|
|
1538
|
+
elif args.command == "sync":
|
|
1539
|
+
result = sync_project(
|
|
1540
|
+
args.project, args.client, home=args.home, apply=args.apply
|
|
1541
|
+
)
|
|
1542
|
+
elif args.command == "bootstrap":
|
|
1543
|
+
result = install_plugin(
|
|
1544
|
+
args.plugin,
|
|
1545
|
+
args.client,
|
|
1546
|
+
home=args.home,
|
|
1547
|
+
apply=args.apply,
|
|
1548
|
+
replace=args.replace,
|
|
1549
|
+
recovery_root=args.recovery_root,
|
|
1550
|
+
)
|
|
1551
|
+
else:
|
|
1552
|
+
result = reconcile_project(
|
|
1553
|
+
args.project,
|
|
1554
|
+
args.client,
|
|
1555
|
+
home=args.home,
|
|
1556
|
+
retire_skills=args.retire_skill,
|
|
1557
|
+
retire_plugins=args.retire_plugin,
|
|
1558
|
+
recovery_root=args.recovery_root,
|
|
1559
|
+
apply=args.apply,
|
|
1560
|
+
retire_only=args.retire_only,
|
|
1561
|
+
)
|
|
1562
|
+
_print(result, args.output)
|
|
1563
|
+
return 0 if result.get("ok", True) else 1
|
|
1564
|
+
except AgentPackError as exc:
|
|
1565
|
+
error = {"ok": False, "error": str(exc)}
|
|
1566
|
+
_print(error, args.output)
|
|
1567
|
+
if args.output == "text":
|
|
1568
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1569
|
+
return 2
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
if __name__ == "__main__":
|
|
1573
|
+
raise SystemExit(main())
|