@sakanano-moe/agenthelm 0.0.0-stage → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1048 @@
1
+ #!/usr/bin/env python3
2
+ """agenthelm:在 Claude Code、Codex、Cursor 间安装、检查与更新 agenthelm 插件。
3
+
4
+ ``check`` 只读:比较各客户端已安装的插件版本、项目里由模板渲染的文件,
5
+ 以及 Opinion 来源与云端 ``latest.json``。``install``、``update``、``migrate``
6
+ 默认只预览,``--apply`` 才执行。通过 ``npx @sakanano-moe/agenthelm <命令>`` 或
7
+ ``python3 <agent-pack>/runtime/agenthelm.py <命令>`` 运行。``--hook`` 供会话开始时静默调用:
8
+ 每天至多联网一次、离线不报错、只在有更新时输出一行,可用环境变量
9
+ ``AGENTHELM_NO_UPDATE_CHECK=1`` 或用户配置 ``{"update_check": false}`` 关闭。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tarfile
22
+ import tempfile
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+ from dataclasses import dataclass, field
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ MARKETPLACE = "agenthelm"
32
+ CLI = "npx @sakanano-moe/agenthelm"
33
+ DEFAULT_LATEST_URLS = (
34
+ "https://agenthelm.sakanano.moe/latest.json",
35
+ "https://raw.githubusercontent.com/lc4t/agenthelm/main/latest.json",
36
+ )
37
+ ENV_LATEST = "AGENTHELM_LATEST_URL"
38
+ ENV_DISABLE = "AGENTHELM_NO_UPDATE_CHECK"
39
+ CACHE_TTL_SECONDS = 24 * 3600
40
+ FETCH_TIMEOUT_SECONDS = 3
41
+ HOOK_FETCH_TIMEOUT_SECONDS = 2
42
+ FAILURE_BACKOFF_SECONDS = 6 * 3600
43
+ CONTENT_LOCK = Path(".agents/moe.sakanano.agent-pack/content.lock.json")
44
+ OPINION_LOCK = Path("opinion.lock.json")
45
+ RUNTIME_DIR = Path(__file__).resolve().parent
46
+ PLUGIN_ROOT = RUNTIME_DIR.parent
47
+
48
+
49
+ class CheckError(Exception):
50
+ pass
51
+
52
+
53
+ def version_key(value: str) -> tuple[int, ...] | None:
54
+ match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:[-+].*)?", value or "")
55
+ return tuple(int(part) for part in match.groups()) if match else None
56
+
57
+
58
+ def is_newer(candidate: str, current: str) -> bool:
59
+ new, old = version_key(candidate), version_key(current)
60
+ return new is not None and old is not None and new > old
61
+
62
+
63
+ def read_json(path: Path) -> Any:
64
+ return json.loads(path.read_text(encoding="utf-8"))
65
+
66
+
67
+ def write_json_atomic(path: Path, data: Any) -> None:
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
70
+ temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
71
+ os.replace(temporary, path)
72
+
73
+
74
+ def sha256_file(path: Path) -> str:
75
+ return hashlib.sha256(path.read_bytes()).hexdigest()
76
+
77
+
78
+ # ---------------------------------------------------------------- environment
79
+
80
+ @dataclass
81
+ class Env:
82
+ home: Path
83
+ variables: dict[str, str] = field(default_factory=dict)
84
+
85
+ def child_environment(self) -> dict[str, str]:
86
+ """Environment for client CLIs: they must see the same home this run inspects."""
87
+ child = dict(self.variables)
88
+ child["HOME"] = str(self.home)
89
+ return child
90
+
91
+ def get(self, name: str) -> str | None:
92
+ value = self.variables.get(name)
93
+ return value if value else None
94
+
95
+ @property
96
+ def cache_dir(self) -> Path:
97
+ base = self.get("XDG_CACHE_HOME")
98
+ return (Path(base) if base else self.home / ".cache") / "agenthelm"
99
+
100
+ @property
101
+ def config_file(self) -> Path:
102
+ base = self.get("XDG_CONFIG_HOME")
103
+ return (Path(base) if base else self.home / ".config") / "agenthelm" / "config.json"
104
+
105
+ @property
106
+ def claude_dir(self) -> Path:
107
+ configured = self.get("CLAUDE_CONFIG_DIR")
108
+ return Path(configured) if configured else self.home / ".claude"
109
+
110
+ @property
111
+ def codex_dir(self) -> Path:
112
+ configured = self.get("CODEX_HOME")
113
+ return Path(configured) if configured else self.home / ".codex"
114
+
115
+
116
+ def current_env(home: Path | None = None) -> Env:
117
+ variables = dict(os.environ)
118
+ if home is not None:
119
+ # An explicit home must not inherit locations that point back at the real one.
120
+ for name in ("CLAUDE_CONFIG_DIR", "CODEX_HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME"):
121
+ variables.pop(name, None)
122
+ return Env(home=(home or Path.home()), variables=variables)
123
+
124
+
125
+ def update_check_enabled(env: Env) -> bool:
126
+ if env.get(ENV_DISABLE) not in (None, "0", "false"):
127
+ return False
128
+ try:
129
+ return read_json(env.config_file).get("update_check", True) is not False
130
+ except (OSError, ValueError, AttributeError):
131
+ return True
132
+
133
+
134
+ # ---------------------------------------------------------------- cloud source
135
+
136
+ def _fetch(url: str, timeout: float = FETCH_TIMEOUT_SECONDS) -> dict:
137
+ request = urllib.request.Request(url, headers={"User-Agent": "agenthelm-check"})
138
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
139
+ data = json.loads(response.read().decode("utf-8"))
140
+ if not isinstance(data, dict) or data.get("marketplace") != MARKETPLACE:
141
+ raise CheckError(f"{url} is not an agenthelm latest.json")
142
+ return data
143
+
144
+
145
+ def load_latest(env: Env, source: str | None = None, *, offline: bool = False,
146
+ max_age: int = CACHE_TTL_SECONDS, timeout: float = FETCH_TIMEOUT_SECONDS,
147
+ backoff: bool = False) -> tuple[dict, dict]:
148
+ """Return (latest, origin). A local path is read directly; URLs go through a daily cache."""
149
+ source = source or env.get(ENV_LATEST)
150
+ if source and not re.match(r"https?://", source):
151
+ return read_json(Path(source).expanduser()), {"source": source, "cached": False}
152
+ urls = (source,) if source else DEFAULT_LATEST_URLS
153
+ cache_file = env.cache_dir / "latest.json"
154
+ cached = None
155
+ try:
156
+ cached = read_json(cache_file)
157
+ except (OSError, ValueError):
158
+ pass
159
+ if cached and (offline or time.time() - cached.get("fetched_at", 0) < max_age):
160
+ return cached["latest"], {"source": cached.get("url"), "cached": True, "fetched_at": cached.get("fetched_at")}
161
+ if offline:
162
+ raise CheckError("离线且没有缓存的 latest.json")
163
+ failure_file = env.cache_dir / "last_failure.json"
164
+ if backoff:
165
+ try:
166
+ if time.time() - read_json(failure_file).get("at", 0) < FAILURE_BACKOFF_SECONDS:
167
+ if cached:
168
+ return cached["latest"], {"source": cached.get("url"), "cached": True, "stale": True}
169
+ raise CheckError("最近联网失败,稍后再试")
170
+ except (OSError, ValueError, AttributeError):
171
+ pass
172
+ errors = []
173
+ for url in urls:
174
+ try:
175
+ latest = _fetch(url, timeout)
176
+ except (OSError, ValueError, CheckError, urllib.error.URLError) as exc:
177
+ errors.append(f"{url}: {exc}")
178
+ continue
179
+ try:
180
+ write_json_atomic(cache_file, {"url": url, "fetched_at": time.time(), "latest": latest})
181
+ except OSError:
182
+ pass
183
+ return latest, {"source": url, "cached": False}
184
+ try:
185
+ write_json_atomic(failure_file, {"at": time.time()})
186
+ except OSError:
187
+ pass
188
+ if cached:
189
+ return cached["latest"], {"source": cached.get("url"), "cached": True, "stale": True,
190
+ "fetched_at": cached.get("fetched_at")}
191
+ raise CheckError("无法获取 latest.json:" + "; ".join(errors))
192
+
193
+
194
+ # ---------------------------------------------------------------- installed plugins
195
+
196
+ def _manifest_version(path: Path) -> str | None:
197
+ try:
198
+ return str(read_json(path / "plugin.json").get("version"))
199
+ except (OSError, ValueError, AttributeError):
200
+ return None
201
+
202
+
203
+ def installed_plugins(env: Env, names: set[str]) -> list[dict]:
204
+ found: list[dict] = []
205
+ try:
206
+ record = read_json(env.claude_dir / "plugins" / "installed_plugins.json").get("plugins", {})
207
+ except (OSError, ValueError, AttributeError):
208
+ record = {}
209
+ for key, entries in sorted(record.items()):
210
+ plugin, _, market = key.partition("@")
211
+ if market != MARKETPLACE or plugin not in names:
212
+ continue
213
+ for entry in entries:
214
+ path = Path(str(entry.get("installPath", "")))
215
+ # A git-SHA version means the entry had no version; fall back to the manifest.
216
+ version = entry.get("version") if version_key(str(entry.get("version"))) else _manifest_version(path)
217
+ found.append({"client": "claude", "plugin": plugin, "version": version, "path": str(path),
218
+ "scope": entry.get("scope")})
219
+ codex_cache = env.codex_dir / "plugins" / "cache" / MARKETPLACE
220
+ for plugin in sorted(names):
221
+ directories = [path for path in (codex_cache / plugin).glob("*") if (path / "plugin.json").is_file()]
222
+ if directories:
223
+ newest = max(directories, key=lambda path: version_key(path.name) or (0,))
224
+ found.append({"client": "codex", "plugin": plugin, "version": _manifest_version(newest),
225
+ "path": str(newest)})
226
+ for client, base in (("cursor", env.home / ".cursor" / "plugins" / "local"),
227
+ ("legacy", env.home / ".agents" / "plugins" / "plugins"),
228
+ ("legacy", env.home / ".claude" / "agent-pack" / "plugins")):
229
+ for plugin in sorted(names):
230
+ path = base / plugin
231
+ if (path / "plugin.json").is_file():
232
+ found.append({"client": client, "plugin": plugin, "version": _manifest_version(path),
233
+ "path": str(path)})
234
+ return found
235
+
236
+
237
+ def migrations_between(latest: dict, plugin: str, current: str, target: str) -> list[dict]:
238
+ steps = []
239
+ for entry in latest.get("migrations", []):
240
+ if entry.get("plugin") != plugin:
241
+ continue
242
+ to = str(entry.get("to"))
243
+ if is_newer(to, current) and not is_newer(to, target):
244
+ steps.append(entry)
245
+ return sorted(steps, key=lambda entry: version_key(str(entry.get("to"))) or (0,))
246
+
247
+
248
+ def plugin_findings(latest: dict, installed: list[dict]) -> list[dict]:
249
+ findings = []
250
+ for item in installed:
251
+ target = latest.get("plugins", {}).get(item["plugin"], {}).get("version")
252
+ current = item.get("version") or ""
253
+ if target is None:
254
+ status = "unknown"
255
+ elif is_newer(target, current):
256
+ status = "behind"
257
+ elif is_newer(current, target):
258
+ status = "ahead"
259
+ elif version_key(current) is None:
260
+ status = "unknown"
261
+ else:
262
+ status = "current"
263
+ steps = migrations_between(latest, item["plugin"], current, target) if status == "behind" else []
264
+ kinds = sorted({step.get("change_kind") for step in steps if step.get("change_kind")})
265
+ findings.append({
266
+ **item,
267
+ "latest": target,
268
+ "status": status,
269
+ "change_kinds": kinds,
270
+ "breaking": any(step.get("breaking") for step in steps),
271
+ "steps": [{"to": step.get("to"), "change_kind": step.get("change_kind"),
272
+ "breaking": step.get("breaking"), "summary": step.get("summary"),
273
+ "actions": step.get("actions", [])} for step in steps],
274
+ })
275
+ return findings
276
+
277
+
278
+ # ---------------------------------------------------------------- project content
279
+
280
+ def content_findings(project: Path, latest: dict) -> list[dict]:
281
+ lock_path = project / CONTENT_LOCK
282
+ if not lock_path.is_file():
283
+ return []
284
+ try:
285
+ lock = read_json(lock_path)
286
+ except (OSError, ValueError) as exc:
287
+ return [{"pack": None, "status": "error", "error": f"{CONTENT_LOCK} 无法解析:{exc}"}]
288
+ findings = []
289
+ for pack_id, entry in sorted(lock.get("packs", {}).items()):
290
+ plugin = entry.get("plugin") or pack_id.split("/", 1)[0]
291
+ target = latest.get("plugins", {}).get(plugin, {}).get("content", {}).get(pack_id, {}).get("version")
292
+ files = []
293
+ for relative, digest in sorted(entry.get("files", {}).items()):
294
+ path = project / relative
295
+ if not path.is_file():
296
+ state = "missing"
297
+ elif sha256_file(path) == digest:
298
+ state = "clean"
299
+ else:
300
+ state = "modified"
301
+ files.append({"path": relative, "state": state})
302
+ changed = [item["path"] for item in files if item["state"] != "clean"]
303
+ if target is None:
304
+ status = "unknown"
305
+ elif is_newer(target, entry.get("version", "")):
306
+ status = "conflict" if changed else "update-available"
307
+ else:
308
+ status = "current"
309
+ findings.append({"pack": pack_id, "plugin": plugin, "version": entry.get("version"),
310
+ "latest": target, "status": status, "locally_changed": changed, "files": files})
311
+ return findings
312
+
313
+
314
+ def locate_companion(name: str, installed: list[dict]) -> Path | None:
315
+ """The newest copy of a companion plugin: next to this one or in any client install."""
316
+ candidates = [PLUGIN_ROOT.parent / name, *(Path(item["path"]) for item in installed if item["plugin"] == name)]
317
+ candidates = [path for path in candidates if (path / "plugin.json").is_file()]
318
+ return max(candidates, key=lambda path: version_key(_manifest_version(path) or "") or (0,)) if candidates else None
319
+
320
+
321
+ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
322
+ """Delegate to opinion-manager: newer catalog versions of the templates and rules the project locked."""
323
+ lock_path = project / OPINION_LOCK
324
+ if not lock_path.is_file():
325
+ return []
326
+ manager = locate_companion("opinion-manager", installed)
327
+ if manager is None:
328
+ return [{"status": "unknown", "error": "未找到已安装的 opinion-manager"}]
329
+ try:
330
+ profile = read_json(lock_path)["profile"]
331
+ except (OSError, ValueError, KeyError, TypeError) as exc:
332
+ return [{"status": "error", "error": f"{OPINION_LOCK} 无法解析:{exc}"}]
333
+ with tempfile.TemporaryDirectory() as temporary:
334
+ profile_file = Path(temporary) / "profile.json"
335
+ profile_file.write_text(json.dumps(profile, ensure_ascii=False), encoding="utf-8")
336
+ completed = subprocess.run(
337
+ [sys.executable, str(manager / "runtime" / "opinion_manager.py"), "updates",
338
+ "--profile", str(profile_file), "--catalog", str(manager / "catalog")],
339
+ capture_output=True, text=True, check=False, timeout=30,
340
+ )
341
+ try:
342
+ result = json.loads(completed.stdout)
343
+ except ValueError:
344
+ return [{"status": "error", "error": (completed.stderr or completed.stdout).strip()[:300]}]
345
+ if not result.get("ok", False):
346
+ return [{"status": "error", "error": result.get("error", "opinion-manager updates 失败")}]
347
+ return [{"status": "update-available" if result["updates"] else "current", "updates": result["updates"]}]
348
+
349
+
350
+ # ---------------------------------------------------------------- report
351
+
352
+ def run_check(env: Env, project: Path | None, *, latest_source: str | None = None, offline: bool = False,
353
+ max_age: int = CACHE_TTL_SECONDS, hook_mode: bool = False) -> dict:
354
+ latest, origin = load_latest(
355
+ env, latest_source, offline=offline, max_age=max_age,
356
+ timeout=HOOK_FETCH_TIMEOUT_SECONDS if hook_mode else FETCH_TIMEOUT_SECONDS, backoff=hook_mode,
357
+ )
358
+ names = set(latest.get("plugins", {}))
359
+ installed = installed_plugins(env, names)
360
+ plugins = plugin_findings(latest, installed)
361
+ content = content_findings(project, latest) if project else []
362
+ opinion = opinion_findings(project, installed) if project else []
363
+ behind = [item for item in plugins if item["status"] == "behind"]
364
+ legacy = [item for item in plugins if item["client"] == "legacy"]
365
+ content_updates = [item for item in content if item["status"] in {"update-available", "conflict"}]
366
+ opinion_updates = [item for item in opinion if item["status"] == "update-available"]
367
+ return {
368
+ "ok": True,
369
+ "latest": {"source": origin, "released_at": latest.get("released_at")},
370
+ "_latest": latest,
371
+ "plugins": plugins,
372
+ "content": content,
373
+ "opinion": opinion,
374
+ "summary": {
375
+ "plugins_behind": len(behind),
376
+ "breaking": any(item["breaking"] for item in behind),
377
+ "content_updates": len(content_updates),
378
+ "content_conflicts": sum(1 for item in content if item["status"] == "conflict"),
379
+ "opinion_updates": len(opinion_updates),
380
+ "legacy_installs": len(legacy),
381
+ "up_to_date": not (behind or content_updates or opinion_updates or legacy),
382
+ },
383
+ }
384
+
385
+
386
+ UPDATE_HINTS = {
387
+ "claude": "claude plugin marketplace update agenthelm && claude plugin update {plugin}@agenthelm",
388
+ "codex": "codex plugin marketplace upgrade agenthelm",
389
+ "cursor": f"{CLI} update",
390
+ "legacy": f"{CLI} migrate --install",
391
+ }
392
+
393
+
394
+ def one_line(report: dict) -> str | None:
395
+ summary = report["summary"]
396
+ if summary["up_to_date"]:
397
+ return None
398
+ parts = []
399
+ if summary["plugins_behind"]:
400
+ parts.append(f"{summary['plugins_behind']} 个插件可更新" + ("(含不兼容变更)" if summary["breaking"] else ""))
401
+ if summary["content_updates"]:
402
+ parts.append(f"{summary['content_updates']} 个模板/样例包可更新"
403
+ + (f"({summary['content_conflicts']} 个本地有改动,更新时逐文件确认)" if summary["content_conflicts"] else ""))
404
+ if summary["opinion_updates"]:
405
+ parts.append("Opinion 有新版本模板或规则")
406
+ if summary["legacy_installs"]:
407
+ parts.append(f"{summary['legacy_installs']} 个旧方式安装待迁移")
408
+ return "agenthelm:" + ",".join(parts) + f"。运行 `{CLI} check` 查看详情;更新前会先预览,由你决定。"
409
+
410
+
411
+ def render_text(report: dict) -> str:
412
+ lines = []
413
+ origin = report["latest"]["source"]
414
+ lines.append(f"云端版本源:{origin.get('source')}" + ("(缓存)" if origin.get("cached") else ""))
415
+ if not report["plugins"]:
416
+ lines.append("未发现已安装的 agenthelm 插件。")
417
+ for item in report["plugins"]:
418
+ label = {"behind": "可更新", "current": "最新", "ahead": "高于云端", "unknown": "版本未知"}[item["status"]]
419
+ line = f" [{item['client']}] {item['plugin']} {item.get('version')} → {item.get('latest')} {label}"
420
+ if item["status"] == "behind":
421
+ kinds = {"code": "代码", "content": "仅模板/样例", "both": "代码与模板"}
422
+ if item["change_kinds"]:
423
+ line += "(" + "、".join(kinds[kind] for kind in item["change_kinds"]) + ")"
424
+ if item["breaking"]:
425
+ line += "(含不兼容变更)"
426
+ for step in item["steps"]:
427
+ if step.get("breaking"):
428
+ line += f"\n {step['to']} 不兼容:{step.get('summary')}"
429
+ line += "".join(f"\n - {action}" for action in step.get("actions") or [])
430
+ hint = UPDATE_HINTS.get(item["client"])
431
+ if hint:
432
+ line += f"\n 更新:{hint.format(plugin=item['plugin'])}"
433
+ elif item["client"] == "legacy":
434
+ line += f"\n 旧方式安装:{UPDATE_HINTS['legacy']}"
435
+ lines.append(line)
436
+ for item in report["content"]:
437
+ if item.get("pack") is None:
438
+ lines.append(f" 内容锁:{item.get('error')}")
439
+ continue
440
+ label = {"current": "最新", "update-available": "可更新(本地未改动)",
441
+ "conflict": "可更新(本地有改动,更新时逐文件确认)", "unknown": "云端未列出"}[item["status"]]
442
+ line = f" [内容] {item['pack']} {item['version']} → {item.get('latest')} {label}"
443
+ if item["locally_changed"]:
444
+ line += "\n 本地已改动:" + "、".join(item["locally_changed"])
445
+ lines.append(line)
446
+ for item in report["opinion"]:
447
+ if item["status"] == "update-available":
448
+ refs = ";".join(f"{update['current']} → {update['available'][-1]}" for update in item["updates"])
449
+ lines.append(f" [Opinion] 有新版本:{refs}(用 opinion-manager 预览后确认)")
450
+ elif item["status"] in {"error", "unknown"}:
451
+ lines.append(f" [Opinion] {item.get('error')}")
452
+ lines.append(one_line(report) or "全部为最新。")
453
+ return "\n".join(lines) + "\n"
454
+
455
+
456
+ def notice_due(env: Env, message: str) -> bool:
457
+ """Show the same notice at most once per day."""
458
+ state_file = env.cache_dir / "last_notice.json"
459
+ digest = hashlib.sha256(message.encode("utf-8")).hexdigest()
460
+ try:
461
+ state = read_json(state_file)
462
+ if state.get("digest") == digest and time.time() - state.get("at", 0) < CACHE_TTL_SECONDS:
463
+ return False
464
+ except (OSError, ValueError, AttributeError):
465
+ pass
466
+ try:
467
+ write_json_atomic(state_file, {"digest": digest, "at": time.time()})
468
+ except OSError:
469
+ pass
470
+ return True
471
+
472
+
473
+ def hook(env: Env, project: Path | None, output: str) -> int:
474
+ """Session-start entry: silent unless something is behind; never fails the session."""
475
+ if not update_check_enabled(env):
476
+ return 0
477
+ try:
478
+ report = run_check(env, project, hook_mode=True)
479
+ except Exception: # noqa: BLE001 - an update notice must never break a session
480
+ return 0
481
+ message = one_line(report)
482
+ if message is None or not notice_due(env, message):
483
+ return 0
484
+ if output == "claude-hook":
485
+ print(json.dumps({"systemMessage": message, "hookSpecificOutput": {
486
+ "hookEventName": "SessionStart", "additionalContext": message}}, ensure_ascii=False))
487
+ else:
488
+ print(message)
489
+ return 0
490
+
491
+
492
+ # ---------------------------------------------------------------- install / update / migrate
493
+
494
+ CLIENTS = ("claude", "codex", "cursor")
495
+
496
+
497
+ def github_slug(latest: dict) -> str:
498
+ match = re.match(r"https://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", str(latest.get("repository", "")))
499
+ if not match:
500
+ raise CheckError(f"latest.json 的 repository 不是 GitHub 仓库:{latest.get('repository')}")
501
+ return match.group(1)
502
+
503
+
504
+ def detect_clients(env: Env) -> list[str]:
505
+ found = []
506
+ if shutil.which("claude") or env.claude_dir.is_dir():
507
+ found.append("claude")
508
+ if shutil.which("codex") or env.codex_dir.is_dir():
509
+ found.append("codex")
510
+ if (env.home / ".cursor").is_dir():
511
+ found.append("cursor")
512
+ return found
513
+
514
+
515
+ def _safe_extract(archive: Path, destination: Path) -> None:
516
+ with tarfile.open(archive) as bundle:
517
+ for member in bundle.getmembers():
518
+ target = (destination / member.name).resolve()
519
+ if destination.resolve() not in target.parents and target != destination.resolve():
520
+ raise CheckError(f"发布包含越界路径:{member.name}")
521
+ if not (member.isfile() or member.isdir()):
522
+ raise CheckError(f"发布包含不支持的条目:{member.name}")
523
+ bundle.extractall(destination) # noqa: S202 - every member was validated above
524
+
525
+
526
+ def release_source(latest: dict, workdir: Path, source: Path | None = None) -> Path:
527
+ """A directory with plugins/<name>: a local checkout, or the tagged release tarball."""
528
+ if source is not None:
529
+ root = source.expanduser().resolve()
530
+ if not (root / "plugins").is_dir():
531
+ raise CheckError(f"{root} 不含 plugins/ 目录")
532
+ return root
533
+ release = latest.get("release") or {}
534
+ tag, digest = release.get("tag"), release.get("tarball_sha256")
535
+ if not tag or not digest:
536
+ raise CheckError("云端 latest.json 没有发布 tag 与摘要;请用 --source 指定本地仓库")
537
+ url = release.get("tarball_url") or f"https://github.com/{github_slug(latest)}/releases/download/{tag}/agenthelm-{tag}.tar.gz"
538
+ archive = workdir / "release.tar.gz"
539
+ request = urllib.request.Request(url, headers={"User-Agent": "agenthelm"})
540
+ with urllib.request.urlopen(request, timeout=60) as response, archive.open("wb") as handle: # noqa: S310
541
+ shutil.copyfileobj(response, handle)
542
+ if sha256_file(archive) != digest:
543
+ raise CheckError(f"发布包摘要不符:{url}")
544
+ extracted = workdir / "release"
545
+ extracted.mkdir()
546
+ _safe_extract(archive, extracted)
547
+ roots = [path for path in extracted.iterdir() if path.is_dir()]
548
+ if len(roots) != 1 or not (roots[0] / "plugins").is_dir():
549
+ raise CheckError("发布包结构不符合预期")
550
+ return roots[0]
551
+
552
+
553
+ def _agent_pack_config():
554
+ sys.path.insert(0, str(RUNTIME_DIR))
555
+ import agent_pack_config # noqa: PLC0415 - shipped next to this file
556
+
557
+ return agent_pack_config
558
+
559
+
560
+ @dataclass
561
+ class Step:
562
+ """One planned action; ``command`` runs a client CLI, ``action`` runs in-process."""
563
+
564
+ kind: str
565
+ client: str | None
566
+ description: str
567
+ command: list[str] | None = None
568
+ action: Any = None
569
+ blocked: str | None = None
570
+
571
+ def view(self) -> dict:
572
+ data = {"kind": self.kind, "client": self.client, "description": self.description}
573
+ if self.command:
574
+ data["command"] = " ".join(self.command)
575
+ if self.blocked:
576
+ data["blocked"] = self.blocked
577
+ return data
578
+
579
+
580
+ def _need(binary: str) -> str | None:
581
+ return None if shutil.which(binary) else f"未找到 {binary} 命令"
582
+
583
+
584
+ def install_steps(env: Env, latest: dict, clients: list[str], plugins: list[str], source: Path | None,
585
+ home: Path, marketplace_source: str | None = None) -> list[Step]:
586
+ slug = marketplace_source or github_slug(latest)
587
+ steps: list[Step] = []
588
+ for client in clients:
589
+ if client == "claude":
590
+ steps.append(Step("install", "claude", f"Claude Code 添加 agenthelm 市场({slug})",
591
+ ["claude", "plugin", "marketplace", "add", slug], blocked=_need("claude")))
592
+ for plugin in plugins:
593
+ steps.append(Step("install", "claude", f"Claude Code 安装 {plugin}",
594
+ ["claude", "plugin", "install", f"{plugin}@{MARKETPLACE}"], blocked=_need("claude")))
595
+ elif client == "codex":
596
+ steps.append(Step("install", "codex", f"Codex 添加 agenthelm 市场({slug})",
597
+ ["codex", "plugin", "marketplace", "add", slug], blocked=_need("codex")))
598
+ for plugin in plugins:
599
+ steps.append(Step("install", "codex", f"Codex 安装 {plugin}",
600
+ ["codex", "plugin", "add", f"{plugin}@{MARKETPLACE}"], blocked=_need("codex")))
601
+ elif client == "cursor":
602
+ steps.append(cursor_step(latest, plugins, source, home, "安装"))
603
+ return steps
604
+
605
+
606
+ def cursor_step(latest: dict, plugins: list[str], source: Path | None, home: Path, verb: str) -> Step:
607
+ def run(apply: bool, recovery_root: Path | None) -> list[dict]:
608
+ config = _agent_pack_config()
609
+ with tempfile.TemporaryDirectory(prefix="agenthelm-") as temporary:
610
+ root = release_source(latest, Path(temporary), source)
611
+ return [config.install_plugin(root / "plugins" / plugin, "cursor", home=home, apply=apply,
612
+ replace=True, recovery_root=recovery_root)
613
+ for plugin in plugins]
614
+ return Step("install", "cursor", f"Cursor {verb} {'、'.join(plugins)} 到 ~/.cursor/plugins/local(重启 Cursor 生效)",
615
+ action=run)
616
+
617
+
618
+ def update_steps(env: Env, report: dict, project: Path | None, source: Path | None, home: Path,
619
+ accept: list[str], keep: list[str], recovery_dir: Path | None,
620
+ only: str | None) -> list[Step]:
621
+ latest = report["_latest"]
622
+ steps: list[Step] = []
623
+ behind = [item for item in report["plugins"] if item["status"] == "behind"]
624
+ if only in (None, "plugins"):
625
+ claude = sorted({item["plugin"] for item in behind if item["client"] == "claude"})
626
+ if claude:
627
+ steps.append(Step("update", "claude", "刷新 Claude Code 的 agenthelm 市场",
628
+ ["claude", "plugin", "marketplace", "update", MARKETPLACE], blocked=_need("claude")))
629
+ for item in [entry for entry in behind if entry["client"] == "claude"]:
630
+ command = ["claude", "plugin", "update", f"{item['plugin']}@{MARKETPLACE}"]
631
+ if item.get("scope") and item["scope"] != "user":
632
+ command += ["--scope", item["scope"]]
633
+ steps.append(Step("update", "claude", f"Claude Code 更新 {item['plugin']} → {item['latest']}",
634
+ command, blocked=_need("claude")))
635
+ if any(item["client"] == "codex" for item in behind):
636
+ steps.append(Step("update", "codex", "Codex 升级 agenthelm 市场与已装插件",
637
+ ["codex", "plugin", "marketplace", "upgrade", MARKETPLACE], blocked=_need("codex")))
638
+ cursor = sorted({item["plugin"] for item in behind if item["client"] == "cursor"})
639
+ if cursor:
640
+ steps.append(cursor_step(latest, cursor, source, home, "更新"))
641
+ if only in (None, "content") and project is not None:
642
+ for item in report["content"]:
643
+ if item.get("status") not in {"update-available", "conflict"}:
644
+ continue
645
+ steps.append(content_step(env, item, report, project, accept, keep, recovery_dir))
646
+ return steps
647
+
648
+
649
+ def content_step(env: Env, item: dict, report: dict, project: Path, accept: list[str], keep: list[str],
650
+ recovery_dir: Path | None) -> Step:
651
+ if item["plugin"] != "agents-init":
652
+ return Step("content", None, f"{item['pack']}:没有可用的刷新工具", blocked="unsupported")
653
+ stale = any(entry["plugin"] == "agents-init" and entry["status"] == "behind" for entry in report["plugins"])
654
+ description = f"重新渲染 {item['pack']} {item['version']} → {item['latest']}"
655
+
656
+ def run(apply: bool, _recovery_root: Path | None) -> dict:
657
+ installed = installed_plugins(env, {"agents-init"})
658
+ manager = locate_companion("agents-init", installed)
659
+ if manager is None:
660
+ raise CheckError("未找到已安装的 agents-init")
661
+ command = [sys.executable, str(manager / "skills/agents-init/scripts/init_project.py"),
662
+ "--project", str(project), "--mode", "refresh", "--output", "json"]
663
+ for path in accept:
664
+ command += ["--accept", path]
665
+ for path in keep:
666
+ command += ["--keep", path]
667
+ if recovery_dir is not None:
668
+ command += ["--recovery-dir", str(recovery_dir)]
669
+ if apply:
670
+ command.append("--apply")
671
+ completed = subprocess.run(command, capture_output=True, text=True, check=False,
672
+ env=env.child_environment())
673
+ try:
674
+ return json.loads(completed.stdout)
675
+ except ValueError as exc:
676
+ raise CheckError((completed.stderr or completed.stdout).strip()[:300]) from exc
677
+
678
+ blocked = "先更新 agents-init 插件,再用新模板预览" if stale else None
679
+ return Step("content", None, description, action=run, blocked=blocked)
680
+
681
+
682
+ def execute_steps(steps: list[Step], *, apply: bool, recovery_root: Path | None,
683
+ child_env: dict[str, str] | None = None) -> list[dict]:
684
+ results = []
685
+ for step in steps:
686
+ record = step.view()
687
+ if step.blocked:
688
+ record["status"] = "blocked"
689
+ elif step.action is not None:
690
+ try:
691
+ record["result"] = step.action(apply, recovery_root)
692
+ record["status"] = "applied" if apply else "preview"
693
+ except Exception as exc: # noqa: BLE001 - report every step, stop nothing silently
694
+ record["status"], record["error"] = "failed", str(exc)
695
+ elif apply:
696
+ completed = subprocess.run(step.command, capture_output=True, text=True, check=False, env=child_env)
697
+ record["status"] = "applied" if completed.returncode == 0 else "failed"
698
+ output = (completed.stdout + completed.stderr).strip()
699
+ if output:
700
+ record["output"] = output[-600:]
701
+ else:
702
+ record["status"] = "preview"
703
+ results.append(record)
704
+ if apply and record["status"] == "failed":
705
+ break
706
+ return results
707
+
708
+
709
+ # ---------------------------------------------------------------- legacy migration
710
+
711
+ def _personal_marketplace_entries(env: Env, names: set[str]) -> list[str]:
712
+ try:
713
+ data = read_json(env.home / ".agents/plugins/marketplace.json")
714
+ except (OSError, ValueError):
715
+ return []
716
+ return [entry.get("name") for entry in data.get("plugins", []) if entry.get("name") in names]
717
+
718
+
719
+ def _codex_personal_sections(env: Env, names: set[str]) -> list[str]:
720
+ try:
721
+ text = (env.codex_dir / "config.toml").read_text(encoding="utf-8")
722
+ except OSError:
723
+ return []
724
+ return [name for name in sorted(names) if f'[plugins."{name}@personal"]' in text]
725
+
726
+
727
+ def _legacy_roots(env: Env) -> list[Path]:
728
+ return [env.home / ".agents/plugins/plugins", env.home / ".claude/agent-pack/plugins"]
729
+
730
+
731
+ def _points_into(path: Path, roots: list[Path]) -> bool:
732
+ try:
733
+ target = path.resolve()
734
+ except OSError:
735
+ return False
736
+ return any(root.resolve() == target or root.resolve() in target.parents for root in roots if root.exists())
737
+
738
+
739
+ def _is_plugin_skill_dir(path: Path, names: set[str]) -> bool:
740
+ """True for <plugin>/skills/<skill> of an agenthelm plugin, wherever that plugin lives."""
741
+ try:
742
+ target = path.resolve()
743
+ manifest = read_json(target.parent.parent / "plugin.json")
744
+ except (OSError, ValueError):
745
+ return False
746
+ return target.parent.name == "skills" and manifest.get("name") in names
747
+
748
+
749
+ def legacy_artifacts(env: Env, names: set[str]) -> list[dict]:
750
+ """Everything the pre-marketplace agent-pack bootstrap left behind for agenthelm plugins."""
751
+ artifacts: list[dict] = []
752
+ for root in _legacy_roots(env):
753
+ for name in sorted(names):
754
+ path = root / name
755
+ if path.exists() or path.is_symlink():
756
+ artifacts.append({"kind": "plugin-copy" if not path.is_symlink() else "plugin-record",
757
+ "name": name, "path": str(path)})
758
+ skills_dir = env.claude_dir / "skills"
759
+ roots = _legacy_roots(env)
760
+ for name in sorted(names):
761
+ link = skills_dir / name
762
+ if link.is_symlink() and (_points_into(link, roots) or _is_plugin_skill_dir(link, names)):
763
+ artifacts.append({"kind": "claude-skill-link", "name": name, "path": str(link)})
764
+ elif link.is_dir() and not link.is_symlink():
765
+ artifacts.append({"kind": "claude-skill-copy", "name": name, "path": str(link), "manual": True})
766
+ for name in _personal_marketplace_entries(env, names):
767
+ artifacts.append({"kind": "codex-personal-entry", "name": name,
768
+ "path": str(env.home / ".agents/plugins/marketplace.json")})
769
+ for name in _codex_personal_sections(env, names):
770
+ artifacts.append({"kind": "codex-config-section", "name": name, "path": str(env.codex_dir / "config.toml")})
771
+ try:
772
+ servers = read_json(env.home / ".claude.json").get("mcpServers", {})
773
+ except (OSError, ValueError, AttributeError):
774
+ servers = {}
775
+ for server, config in sorted(servers.items()):
776
+ text = json.dumps(config)
777
+ if any(str(root) in text for root in roots) and server in names:
778
+ artifacts.append({"kind": "claude-user-mcp", "name": server, "path": str(env.home / ".claude.json")})
779
+ return artifacts
780
+
781
+
782
+ def _remove_toml_sections(text: str, headers: set[str]) -> str:
783
+ kept, skipping = [], False
784
+ for line in text.splitlines(keepends=True):
785
+ stripped = line.strip()
786
+ if stripped.startswith("["):
787
+ skipping = stripped in headers
788
+ if not skipping:
789
+ kept.append(line)
790
+ return "".join(kept)
791
+
792
+
793
+ def retire_legacy(env: Env, artifacts: list[dict], recovery: Path) -> list[dict]:
794
+ """Move or edit each legacy artifact, keeping an exact copy under ``recovery``."""
795
+ recovery.mkdir(parents=True, mode=0o700)
796
+ done: list[dict] = []
797
+ backed_up: set[str] = set()
798
+
799
+ def backup(path: Path) -> None:
800
+ if str(path) in backed_up:
801
+ return
802
+ target = recovery / "files" / path.relative_to(env.home)
803
+ target.parent.mkdir(parents=True, exist_ok=True)
804
+ shutil.copy2(path, target)
805
+ backed_up.add(str(path))
806
+
807
+ for item in artifacts:
808
+ path = Path(item["path"])
809
+ if item.get("manual"):
810
+ done.append({**item, "status": "manual"})
811
+ continue
812
+ if item["kind"] in {"plugin-copy", "plugin-record", "claude-skill-link"}:
813
+ target = recovery / "moved" / path.relative_to(env.home)
814
+ target.parent.mkdir(parents=True, exist_ok=True)
815
+ os.replace(path, target)
816
+ done.append({**item, "status": "moved", "to": str(target)})
817
+ elif item["kind"] == "codex-personal-entry":
818
+ backup(path)
819
+ data = read_json(path)
820
+ data["plugins"] = [entry for entry in data.get("plugins", []) if entry.get("name") != item["name"]]
821
+ write_json_atomic(path, data)
822
+ done.append({**item, "status": "edited"})
823
+ elif item["kind"] == "codex-config-section":
824
+ backup(path)
825
+ text = path.read_text(encoding="utf-8")
826
+ path.write_text(_remove_toml_sections(text, {f'[plugins."{item["name"]}@personal"]'}), encoding="utf-8")
827
+ done.append({**item, "status": "edited"})
828
+ elif item["kind"] == "claude-user-mcp":
829
+ backup(path)
830
+ data = read_json(path)
831
+ data.get("mcpServers", {}).pop(item["name"], None)
832
+ mode = path.stat().st_mode & 0o777
833
+ write_json_atomic(path, data)
834
+ os.chmod(path, mode)
835
+ done.append({**item, "status": "edited"})
836
+ write_json_atomic(recovery / "migration.json", {"at": datetime.now(UTC).isoformat(), "artifacts": done})
837
+ return done
838
+
839
+
840
+ def recovery_dir_for(env: Env, label: str) -> Path:
841
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
842
+ return env.home / ".agent-pack" / "recovery" / f"{stamp}-agenthelm-{label}"
843
+
844
+
845
+ def locate(env: Env, plugin: str, version: str | None = None) -> dict:
846
+ """Where a plugin is installed, newest first: the stable lookup for downstream wrappers."""
847
+ found = [item for item in installed_plugins(env, {plugin}) if version is None or item.get("version") == version]
848
+ found.sort(key=lambda item: (version_key(item.get("version") or "") or (0,), item["client"] != "legacy"),
849
+ reverse=True)
850
+ return {"ok": bool(found), "plugin": plugin, "version": version,
851
+ "path": found[0]["path"] if found else None, "installs": found}
852
+
853
+
854
+ def doctor(env: Env, report: dict) -> dict:
855
+ issues = []
856
+ by_plugin: dict[str, list[dict]] = {}
857
+ for item in report["plugins"]:
858
+ by_plugin.setdefault(item["plugin"], []).append(item)
859
+ for plugin, items in sorted(by_plugin.items()):
860
+ clients = [item["client"] for item in items]
861
+ if "legacy" in clients and any(client in {"claude", "codex"} for client in clients):
862
+ issues.append({"plugin": plugin, "issue": "duplicate",
863
+ "detail": f"同时存在市场安装与旧方式安装,运行 {CLI} migrate"})
864
+ versions = {item.get("version") for item in items if item["client"] != "legacy"}
865
+ if len(versions) > 1:
866
+ issues.append({"plugin": plugin, "issue": "version-skew",
867
+ "detail": "各客户端版本不一致:" + "、".join(f"{i['client']}={i.get('version')}" for i in items)})
868
+ names = set(report["_latest"].get("plugins", {}))
869
+ installed_names = {item["plugin"] for item in report["plugins"] if item["client"] != "legacy"}
870
+ if installed_names and installed_names != names:
871
+ issues.append({"plugin": None, "issue": "incomplete",
872
+ "detail": "缺少:" + "、".join(sorted(names - installed_names))})
873
+ if not update_check_enabled(env):
874
+ issues.append({"plugin": None, "issue": "update-check-disabled", "detail": "会话内更新检查已关闭"})
875
+ return {"ok": not [issue for issue in issues if issue["issue"] != "update-check-disabled"],
876
+ "issues": issues, "legacy": legacy_artifacts(env, names), "summary": report["summary"]}
877
+
878
+
879
+ def parser() -> argparse.ArgumentParser:
880
+ result = argparse.ArgumentParser(prog="agenthelm", description=__doc__.split("\n\n")[0])
881
+ sub = result.add_subparsers(dest="command", required=True)
882
+
883
+ def common(command: argparse.ArgumentParser, *, project: bool = True) -> None:
884
+ command.add_argument("--latest", help="latest.json 的 URL 或本地路径(测试、离线镜像)")
885
+ command.add_argument("--output", choices=("text", "json"), default="text")
886
+ command.add_argument("--home", type=Path, help=argparse.SUPPRESS)
887
+ if project:
888
+ command.add_argument("--project", type=Path, default=None, help="项目目录;默认当前目录")
889
+ command.add_argument("--no-project", action="store_true", help="不检查项目内容")
890
+
891
+ check = sub.add_parser("check", help="只读检查插件、模板/样例与 Opinion 是否落后于云端")
892
+ common(check)
893
+ check.add_argument("--offline", action="store_true", help="只用缓存,不联网")
894
+ check.add_argument("--refresh", action="store_true", help="忽略一天的缓存,立即联网")
895
+ check.add_argument("--hook", action="store_true", help="会话开始时静默调用:有更新才输出一行")
896
+ check.add_argument("--hook-format", choices=("text", "claude-hook"), default="text")
897
+ check.add_argument("--strict", action="store_true", help="有可更新项时退出码为 1(CI 用)")
898
+
899
+ install = sub.add_parser("install", help="经各客户端的原生市场安装 agenthelm 插件(默认预览)")
900
+ common(install, project=False)
901
+ install.add_argument("--client", action="append", choices=CLIENTS, help="默认安装到检测到的全部客户端")
902
+ install.add_argument("--plugin", action="append", help="默认安装全部插件")
903
+ install.add_argument("--source", type=Path, help="Cursor 安装用的本地仓库(默认下载发布包)")
904
+ install.add_argument("--marketplace-source", help="市场来源(默认 latest.json 的 GitHub 仓库;可用镜像或本地路径)")
905
+ install.add_argument("--apply", action="store_true")
906
+
907
+ update = sub.add_parser("update", help="更新落后的插件与模板/样例(默认预览,确认后 --apply)")
908
+ common(update)
909
+ update.add_argument("--only", choices=("plugins", "content"))
910
+ update.add_argument("--accept", action="append", default=[], help="内容更新:用新模板替换这个本地已改动的文件")
911
+ update.add_argument("--keep", action="append", default=[], help="内容更新:保留这个文件的本地内容")
912
+ update.add_argument("--recovery-dir", type=Path, help="被替换文件的备份目录(项目外)")
913
+ update.add_argument("--source", type=Path, help="Cursor 更新用的本地仓库(默认下载发布包)")
914
+ update.add_argument("--apply", action="store_true")
915
+
916
+ migrate = sub.add_parser("migrate", help="把旧方式安装(agent-pack 复制 + 软链)迁到市场安装(默认预览)")
917
+ common(migrate, project=False)
918
+ migrate.add_argument("--install", action="store_true", help="先经市场安装到检测到的客户端")
919
+ migrate.add_argument("--client", action="append", choices=CLIENTS)
920
+ migrate.add_argument("--source", type=Path)
921
+ migrate.add_argument("--marketplace-source")
922
+ migrate.add_argument("--apply", action="store_true")
923
+
924
+ doctor_parser = sub.add_parser("doctor", help="报告重复安装、版本不一致、旧方式残留与检查开关")
925
+ common(doctor_parser)
926
+
927
+ locate_parser = sub.add_parser("locate", help="输出插件的已安装位置(最新优先),供下游脚本查找")
928
+ locate_parser.add_argument("plugin")
929
+ locate_parser.add_argument("--version", help="只要这个版本")
930
+ locate_parser.add_argument("--output", choices=("text", "json"), default="json")
931
+ locate_parser.add_argument("--home", type=Path, help=argparse.SUPPRESS)
932
+ return result
933
+
934
+
935
+ def _print_steps(results: list[dict], apply: bool) -> None:
936
+ labels = {"preview": "预览", "applied": "完成", "blocked": "阻塞", "failed": "失败"}
937
+ for record in results:
938
+ line = f" [{labels.get(record['status'], record['status'])}] {record['description']}"
939
+ if record.get("command"):
940
+ line += f"\n $ {record['command']}"
941
+ if record.get("blocked"):
942
+ line += f"\n {record['blocked']}"
943
+ if record.get("error"):
944
+ line += f"\n {record['error']}"
945
+ result = record.get("result")
946
+ if isinstance(result, dict) and result.get("mode") == "refresh":
947
+ for item in result.get("files", []):
948
+ line += f"\n {item['action']:8} {item['path']} [{item['state']}]"
949
+ if result.get("hint"):
950
+ line += f"\n {result['hint']}"
951
+ print(line)
952
+ if not apply and results:
953
+ print("以上为预览;确认后追加 --apply 执行。")
954
+
955
+
956
+ def main(argv: list[str] | None = None) -> int:
957
+ args = parser().parse_args(argv)
958
+ env = current_env(args.home)
959
+ home = env.home
960
+ project = None
961
+ if hasattr(args, "no_project") and not args.no_project:
962
+ project = (args.project or Path.cwd()).resolve()
963
+ if args.command == "check" and args.hook:
964
+ return hook(env, project, args.hook_format)
965
+ if args.command == "locate":
966
+ found = locate(env, args.plugin, args.version)
967
+ if args.output == "json":
968
+ print(json.dumps(found, ensure_ascii=False, indent=2))
969
+ elif found["path"]:
970
+ print(found["path"])
971
+ return 0 if found["ok"] else 1
972
+ try:
973
+ if args.command == "check":
974
+ report = run_check(env, project, latest_source=args.latest, offline=args.offline,
975
+ max_age=0 if args.refresh else CACHE_TTL_SECONDS)
976
+ report.pop("_latest")
977
+ if args.output == "json":
978
+ print(json.dumps(report, ensure_ascii=False, indent=2))
979
+ else:
980
+ sys.stdout.write(render_text(report))
981
+ return 1 if args.strict and not report["summary"]["up_to_date"] else 0
982
+ if args.command == "doctor":
983
+ result = doctor(env, run_check(env, project, latest_source=args.latest))
984
+ if args.output == "json":
985
+ print(json.dumps(result, ensure_ascii=False, indent=2))
986
+ else:
987
+ for issue in result["issues"]:
988
+ print(f" [{issue['issue']}] {issue.get('plugin') or ''} {issue['detail']}".rstrip())
989
+ for item in result["legacy"]:
990
+ print(f" [legacy] {item['kind']} {item['name']}: {item['path']}")
991
+ print("没有发现问题。" if result["ok"] and not result["legacy"] else "")
992
+ return 0 if result["ok"] else 1
993
+ latest, _ = load_latest(env, args.latest, max_age=0)
994
+ if args.command == "install":
995
+ clients = args.client or detect_clients(env)
996
+ plugins = args.plugin or sorted(latest.get("plugins", {}))
997
+ steps = install_steps(env, latest, clients, plugins, args.source, home, args.marketplace_source)
998
+ results = execute_steps(steps, apply=args.apply, recovery_root=None,
999
+ child_env=env.child_environment())
1000
+ payload: dict = {"ok": all(r["status"] != "failed" for r in results), "applied": args.apply,
1001
+ "clients": clients, "steps": results}
1002
+ elif args.command == "update":
1003
+ report = run_check(env, project, latest_source=args.latest, max_age=0)
1004
+ recovery = args.recovery_dir
1005
+ if args.apply and recovery is None and project is not None:
1006
+ recovery = recovery_dir_for(env, "content")
1007
+ steps = update_steps(env, report, project, args.source, home, args.accept, args.keep,
1008
+ recovery, args.only)
1009
+ results = execute_steps(steps, apply=args.apply, recovery_root=None,
1010
+ child_env=env.child_environment())
1011
+ payload = {"ok": all(r["status"] != "failed" for r in results), "applied": args.apply,
1012
+ "steps": results, "summary": report["summary"]}
1013
+ if not steps:
1014
+ payload["message"] = "没有需要更新的内容。"
1015
+ else: # migrate
1016
+ names = set(latest.get("plugins", {}))
1017
+ artifacts = legacy_artifacts(env, names)
1018
+ steps = install_steps(env, latest, args.client or detect_clients(env), sorted(names),
1019
+ args.source, home, args.marketplace_source) if args.install else []
1020
+ results = execute_steps(steps, apply=args.apply, recovery_root=None,
1021
+ child_env=env.child_environment())
1022
+ payload = {"ok": all(r["status"] != "failed" for r in results), "applied": args.apply,
1023
+ "steps": results, "legacy": artifacts}
1024
+ if args.apply and payload["ok"] and artifacts:
1025
+ recovery = recovery_dir_for(env, "migrate")
1026
+ payload["retired"] = retire_legacy(env, artifacts, recovery)
1027
+ payload["recovery"] = str(recovery)
1028
+ except (CheckError, OSError, ValueError) as exc:
1029
+ if args.output == "json":
1030
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
1031
+ else:
1032
+ print(f"error: {exc}", file=sys.stderr)
1033
+ return 2
1034
+ if args.output == "json":
1035
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1036
+ else:
1037
+ if payload.get("message"):
1038
+ print(payload["message"])
1039
+ _print_steps(payload["steps"], args.apply)
1040
+ for item in payload.get("legacy", []):
1041
+ print(f" [旧安装] {item['kind']} {item['name']}: {item['path']}" + ("(需手动处理)" if item.get("manual") else ""))
1042
+ if payload.get("recovery"):
1043
+ print(f"旧安装已移入:{payload['recovery']}(含 migration.json 清单,可按清单恢复)")
1044
+ return 0 if payload["ok"] else 1
1045
+
1046
+
1047
+ if __name__ == "__main__":
1048
+ raise SystemExit(main())