@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.
@@ -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())