@sakanano-moe/agenthelm 6.5.1 → 6.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakanano-moe/agenthelm",
3
- "version": "6.5.1",
3
+ "version": "6.5.3",
4
4
  "description": "agenthelm 命令行:在 Claude Code、Codex、Cursor 间安装、检查与更新 agenthelm 项目管理插件。",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -485,6 +485,34 @@ def _client_plugin_roots(client: str, home: Path) -> list[Path]:
485
485
  return []
486
486
 
487
487
 
488
+ def _version_tuple(value: str) -> tuple[int, ...]:
489
+ numbers = re.findall(r"\d+", value.split("+", 1)[0])
490
+ return tuple(int(number) for number in numbers)
491
+
492
+
493
+ def _client_marketplace_plugins(client: str, home: Path) -> list[Path]:
494
+ """Plugin roots the client installed from marketplaces (not agent-pack bootstrap copies)."""
495
+ found: list[Path] = []
496
+ if client == "claude":
497
+ try:
498
+ records = _read_json(home / ".claude" / "plugins" / "installed_plugins.json").get("plugins", {})
499
+ except AgentPackError:
500
+ records = {}
501
+ for entries in records.values() if isinstance(records, dict) else ():
502
+ for entry in entries if isinstance(entries, list) else ():
503
+ path = Path(str(entry.get("installPath", "")))
504
+ if (path / "plugin.json").is_file():
505
+ found.append(path)
506
+ elif client == "codex":
507
+ cache = home / ".codex" / "plugins" / "cache"
508
+ if cache.is_dir() and not cache.is_symlink():
509
+ for plugin_dir in sorted(path for market in cache.iterdir() if market.is_dir() for path in market.iterdir()):
510
+ versions = [path for path in plugin_dir.iterdir() if (path / "plugin.json").is_file()] if plugin_dir.is_dir() else []
511
+ if versions:
512
+ found.append(max(versions, key=lambda path: (_version_tuple(path.name), path.name)))
513
+ return found
514
+
515
+
488
516
  def _claude_plugin_records(home: Path) -> Path:
489
517
  """Install records for Claude Code: one symlink per plugin to its source root."""
490
518
  return home / ".claude" / "agent-pack" / "plugins"
@@ -527,6 +555,8 @@ def inventory_client(client: str, home: Path | None = None) -> Inventory:
527
555
  for child in sorted(plugin_parent.iterdir(), key=lambda item: item.name):
528
556
  if child.is_dir() and (child / "plugin.json").is_file():
529
557
  _merge_inventory(inventory, _inventory_plugin(child, inventory.source))
558
+ for child in _client_marketplace_plugins(client, home):
559
+ _merge_inventory(inventory, _inventory_plugin(child, inventory.source))
530
560
  known = {Path(item.path).resolve() for item in inventory.skills}
531
561
  for skill_root in _client_skill_roots(client, home):
532
562
  if not skill_root.is_dir() or skill_root.is_symlink():
@@ -37,7 +37,8 @@ DEFAULT_LATEST_URLS = (
37
37
  ENV_LATEST = "AGENTHELM_LATEST_URL"
38
38
  ENV_DISABLE = "AGENTHELM_NO_UPDATE_CHECK"
39
39
  CACHE_TTL_SECONDS = 24 * 3600
40
- FETCH_TIMEOUT_SECONDS = 3
40
+ # Interactive commands wait longer: some networks reach the site slowly.
41
+ FETCH_TIMEOUT_SECONDS = 10
41
42
  HOOK_FETCH_TIMEOUT_SECONDS = 2
42
43
  FAILURE_BACKOFF_SECONDS = 6 * 3600
43
44
  CONTENT_LOCK = Path(".agents/moe.sakanano.agent-pack/content.lock.json")
@@ -133,10 +134,14 @@ def update_check_enabled(env: Env) -> bool:
133
134
 
134
135
  # ---------------------------------------------------------------- cloud source
135
136
 
136
- def _fetch(url: str, timeout: float = FETCH_TIMEOUT_SECONDS) -> dict:
137
+ def _fetch_json(url: str, timeout: float = FETCH_TIMEOUT_SECONDS) -> Any:
137
138
  request = urllib.request.Request(url, headers={"User-Agent": "agenthelm-check"})
138
139
  with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
139
- data = json.loads(response.read().decode("utf-8"))
140
+ return json.loads(response.read().decode("utf-8"))
141
+
142
+
143
+ def _fetch(url: str, timeout: float = FETCH_TIMEOUT_SECONDS) -> dict:
144
+ data = _fetch_json(url, timeout)
140
145
  if not isinstance(data, dict) or data.get("marketplace") != MARKETPLACE:
141
146
  raise CheckError(f"{url} is not an agenthelm latest.json")
142
147
  return data
@@ -250,7 +255,9 @@ def plugin_findings(latest: dict, installed: list[dict]) -> list[dict]:
250
255
  for item in installed:
251
256
  target = latest.get("plugins", {}).get(item["plugin"], {}).get("version")
252
257
  current = item.get("version") or ""
253
- if target is None:
258
+ if item["client"] == "legacy":
259
+ status = "legacy" # a pre-marketplace copy: it gets migrated, not updated
260
+ elif target is None:
254
261
  status = "unknown"
255
262
  elif is_newer(target, current):
256
263
  status = "behind"
@@ -331,7 +338,8 @@ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
331
338
  except (OSError, ValueError, KeyError, TypeError) as exc:
332
339
  return [{"status": "error", "error": f"{OPINION_LOCK} 无法解析:{exc}"}]
333
340
  with tempfile.TemporaryDirectory() as temporary:
334
- profile_file = Path(temporary) / "profile.json"
341
+ # macOS temporary paths live under /var, a symlink opinion-manager refuses.
342
+ profile_file = Path(temporary).resolve() / "profile.json"
335
343
  profile_file.write_text(json.dumps(profile, ensure_ascii=False), encoding="utf-8")
336
344
  completed = subprocess.run(
337
345
  [sys.executable, str(manager / "runtime" / "opinion_manager.py"), "updates",
@@ -415,7 +423,11 @@ def render_text(report: dict) -> str:
415
423
  if not report["plugins"]:
416
424
  lines.append("未发现已安装的 agenthelm 插件。")
417
425
  for item in report["plugins"]:
418
- label = {"behind": "可更新", "current": "最新", "ahead": "高于云端", "unknown": "版本未知"}[item["status"]]
426
+ label = {"behind": "可更新", "current": "最新", "ahead": "高于云端", "unknown": "版本未知",
427
+ "legacy": "旧方式安装,待迁移"}[item["status"]]
428
+ if item["status"] == "legacy":
429
+ lines.append(f" [旧方式] {item['plugin']} {item.get('version')} {label}")
430
+ continue
419
431
  line = f" [{item['client']}] {item['plugin']} {item.get('version')} → {item.get('latest')} {label}"
420
432
  if item["status"] == "behind":
421
433
  kinds = {"code": "代码", "content": "仅模板/样例", "both": "代码与模板"}
@@ -430,8 +442,6 @@ def render_text(report: dict) -> str:
430
442
  hint = UPDATE_HINTS.get(item["client"])
431
443
  if hint:
432
444
  line += f"\n 更新:{hint.format(plugin=item['plugin'])}"
433
- elif item["client"] == "legacy":
434
- line += f"\n 旧方式安装:{UPDATE_HINTS['legacy']}"
435
445
  lines.append(line)
436
446
  for item in report["content"]:
437
447
  if item.get("pack") is None:
@@ -452,6 +462,8 @@ def render_text(report: dict) -> str:
452
462
  if not report["plugins"]:
453
463
  lines.append(f"运行 `{CLI} install` 预览安装。")
454
464
  else:
465
+ if any(item["status"] == "legacy" for item in report["plugins"]):
466
+ lines.append(f"旧方式安装可用 `{UPDATE_HINTS['legacy']}` 预览迁移。")
455
467
  lines.append(one_line(report) or "全部为最新。")
456
468
  return "\n".join(lines) + "\n"
457
469
 
@@ -506,9 +518,9 @@ def github_slug(latest: dict) -> str:
506
518
 
507
519
  def detect_clients(env: Env) -> list[str]:
508
520
  found = []
509
- if shutil.which("claude") or env.claude_dir.is_dir():
521
+ if find_cli(env, "claude") or env.claude_dir.is_dir():
510
522
  found.append("claude")
511
- if shutil.which("codex") or env.codex_dir.is_dir():
523
+ if find_cli(env, "codex") or env.codex_dir.is_dir():
512
524
  found.append("codex")
513
525
  if (env.home / ".cursor").is_dir():
514
526
  found.append("cursor")
@@ -523,7 +535,29 @@ def _safe_extract(archive: Path, destination: Path) -> None:
523
535
  raise CheckError(f"发布包含越界路径:{member.name}")
524
536
  if not (member.isfile() or member.isdir()):
525
537
  raise CheckError(f"发布包含不支持的条目:{member.name}")
526
- bundle.extractall(destination) # noqa: S202 - every member was validated above
538
+ try:
539
+ bundle.extractall(destination, filter="data") # noqa: S202 - members validated above
540
+ except TypeError: # Python without extraction filters
541
+ bundle.extractall(destination) # noqa: S202
542
+
543
+
544
+ def _plugin_versions(latest: dict) -> dict:
545
+ return {name: item.get("version") for name, item in latest.get("plugins", {}).items()}
546
+
547
+
548
+ def github_release(latest: dict) -> dict:
549
+ """Release details straight from GitHub, used when latest.json came from the raw fallback.
550
+
551
+ Accepted only when that tag ships exactly the plugin versions ``latest`` lists,
552
+ the same rule the site applies before attaching a release.
553
+ """
554
+ try:
555
+ slug = github_slug(latest)
556
+ release = _fetch_json(f"https://github.com/{slug}/releases/latest/download/release.json")
557
+ tagged = _fetch_json(f"https://raw.githubusercontent.com/{slug}/{release['tag']}/latest.json")
558
+ except (OSError, ValueError, KeyError, TypeError, CheckError, urllib.error.URLError):
559
+ return {}
560
+ return release if _plugin_versions(tagged) == _plugin_versions(latest) else {}
527
561
 
528
562
 
529
563
  def release_source(latest: dict, workdir: Path, source: Path | None = None) -> Path:
@@ -533,7 +567,7 @@ def release_source(latest: dict, workdir: Path, source: Path | None = None) -> P
533
567
  if not (root / "plugins").is_dir():
534
568
  raise CheckError(f"{root} 不含 plugins/ 目录")
535
569
  return root
536
- release = latest.get("release") or {}
570
+ release = latest.get("release") or github_release(latest)
537
571
  tag, digest = release.get("tag"), release.get("tarball_sha256")
538
572
  if not tag or not digest:
539
573
  raise CheckError("云端 latest.json 没有发布 tag 与摘要;请用 --source 指定本地仓库")
@@ -580,8 +614,34 @@ class Step:
580
614
  return data
581
615
 
582
616
 
583
- def _need(binary: str) -> str | None:
584
- return None if shutil.which(binary) else f"未找到 {binary} 命令"
617
+ def known_cli_locations(env: Env, name: str) -> list[Path]:
618
+ """Where a client's command line lives when it is not on PATH (GUI apps, per-user installs)."""
619
+ home = env.home
620
+ if name == "codex":
621
+ bundle = "ChatGPT.app/Contents/Resources/codex-cli/bin/codex"
622
+ return [Path("/Applications") / bundle, home / "Applications" / bundle,
623
+ home / ".local/bin/codex", Path("/opt/homebrew/bin/codex"), Path("/usr/local/bin/codex")]
624
+ return [home / ".local/bin" / name, Path("/opt/homebrew/bin") / name, Path("/usr/local/bin") / name]
625
+
626
+
627
+ def find_cli(env: Env, name: str) -> str | None:
628
+ """A client's command line: ``AGENTHELM_<NAME>`` override, then PATH, then the usual app locations."""
629
+ override = env.get(f"AGENTHELM_{name.upper()}")
630
+ if override and os.access(override, os.X_OK):
631
+ return override
632
+ on_path = shutil.which(name, path=env.get("PATH") or "")
633
+ if on_path:
634
+ return on_path
635
+ for candidate in known_cli_locations(env, name):
636
+ if candidate.is_file() and os.access(candidate, os.X_OK):
637
+ return str(candidate)
638
+ return None
639
+
640
+
641
+ def _cli(env: Env, name: str, *args: str) -> tuple[list[str], str | None]:
642
+ """The command (using the resolved executable) and why it cannot run, if it cannot."""
643
+ found = find_cli(env, name)
644
+ return [found or name, *args], (None if found else f"未找到 {name} 命令(已查 PATH 与常见安装位置;可用 AGENTHELM_{name.upper()} 指定)")
585
645
 
586
646
 
587
647
  def install_steps(env: Env, latest: dict, clients: list[str], plugins: list[str], source: Path | None,
@@ -590,17 +650,17 @@ def install_steps(env: Env, latest: dict, clients: list[str], plugins: list[str]
590
650
  steps: list[Step] = []
591
651
  for client in clients:
592
652
  if client == "claude":
593
- steps.append(Step("install", "claude", f"Claude Code 添加 agenthelm 市场({slug})",
594
- ["claude", "plugin", "marketplace", "add", slug], blocked=_need("claude")))
653
+ command, blocked = _cli(env, "claude", "plugin", "marketplace", "add", slug)
654
+ steps.append(Step("install", "claude", f"Claude Code 添加 agenthelm 市场({slug})", command, blocked=blocked))
595
655
  for plugin in plugins:
596
- steps.append(Step("install", "claude", f"Claude Code 安装 {plugin}",
597
- ["claude", "plugin", "install", f"{plugin}@{MARKETPLACE}"], blocked=_need("claude")))
656
+ command, blocked = _cli(env, "claude", "plugin", "install", f"{plugin}@{MARKETPLACE}")
657
+ steps.append(Step("install", "claude", f"Claude Code 安装 {plugin}", command, blocked=blocked))
598
658
  elif client == "codex":
599
- steps.append(Step("install", "codex", f"Codex 添加 agenthelm 市场({slug})",
600
- ["codex", "plugin", "marketplace", "add", slug], blocked=_need("codex")))
659
+ command, blocked = _cli(env, "codex", "plugin", "marketplace", "add", slug)
660
+ steps.append(Step("install", "codex", f"Codex 添加 agenthelm 市场({slug})", command, blocked=blocked))
601
661
  for plugin in plugins:
602
- steps.append(Step("install", "codex", f"Codex 安装 {plugin}",
603
- ["codex", "plugin", "add", f"{plugin}@{MARKETPLACE}"], blocked=_need("codex")))
662
+ command, blocked = _cli(env, "codex", "plugin", "add", f"{plugin}@{MARKETPLACE}")
663
+ steps.append(Step("install", "codex", f"Codex 安装 {plugin}", command, blocked=blocked))
604
664
  elif client == "cursor":
605
665
  steps.append(cursor_step(latest, plugins, source, home, "安装"))
606
666
  return steps
@@ -627,17 +687,16 @@ def update_steps(env: Env, report: dict, project: Path | None, source: Path | No
627
687
  if only in (None, "plugins"):
628
688
  claude = sorted({item["plugin"] for item in behind if item["client"] == "claude"})
629
689
  if claude:
630
- steps.append(Step("update", "claude", "刷新 Claude Code 的 agenthelm 市场",
631
- ["claude", "plugin", "marketplace", "update", MARKETPLACE], blocked=_need("claude")))
690
+ command, blocked = _cli(env, "claude", "plugin", "marketplace", "update", MARKETPLACE)
691
+ steps.append(Step("update", "claude", "刷新 Claude Code 的 agenthelm 市场", command, blocked=blocked))
632
692
  for item in [entry for entry in behind if entry["client"] == "claude"]:
633
- command = ["claude", "plugin", "update", f"{item['plugin']}@{MARKETPLACE}"]
634
- if item.get("scope") and item["scope"] != "user":
635
- command += ["--scope", item["scope"]]
693
+ extra = ["--scope", item["scope"]] if item.get("scope") and item["scope"] != "user" else []
694
+ command, blocked = _cli(env, "claude", "plugin", "update", f"{item['plugin']}@{MARKETPLACE}", *extra)
636
695
  steps.append(Step("update", "claude", f"Claude Code 更新 {item['plugin']} → {item['latest']}",
637
- command, blocked=_need("claude")))
696
+ command, blocked=blocked))
638
697
  if any(item["client"] == "codex" for item in behind):
639
- steps.append(Step("update", "codex", "Codex 升级 agenthelm 市场与已装插件",
640
- ["codex", "plugin", "marketplace", "upgrade", MARKETPLACE], blocked=_need("codex")))
698
+ command, blocked = _cli(env, "codex", "plugin", "marketplace", "upgrade", MARKETPLACE)
699
+ steps.append(Step("update", "codex", "Codex 升级 agenthelm 市场与已装插件", command, blocked=blocked))
641
700
  cursor = sorted({item["plugin"] for item in behind if item["client"] == "cursor"})
642
701
  if cursor:
643
702
  steps.append(cursor_step(latest, cursor, source, home, "更新"))
@@ -771,6 +830,10 @@ def legacy_artifacts(env: Env, names: set[str]) -> list[dict]:
771
830
  "path": str(env.home / ".agents/plugins/marketplace.json")})
772
831
  for name in _codex_personal_sections(env, names):
773
832
  artifacts.append({"kind": "codex-config-section", "name": name, "path": str(env.codex_dir / "config.toml")})
833
+ for name in sorted(names):
834
+ cache = env.codex_dir / "plugins" / "cache" / "personal" / name
835
+ if cache.is_dir() and not cache.is_symlink():
836
+ artifacts.append({"kind": "codex-personal-cache", "name": name, "path": str(cache)})
774
837
  try:
775
838
  servers = read_json(env.home / ".claude.json").get("mcpServers", {})
776
839
  except (OSError, ValueError, AttributeError):
@@ -799,10 +862,17 @@ def retire_legacy(env: Env, artifacts: list[dict], recovery: Path) -> list[dict]
799
862
  done: list[dict] = []
800
863
  backed_up: set[str] = set()
801
864
 
865
+ def inside_recovery(path: Path) -> Path:
866
+ """The path below the recovery folder: home-relative, or absolute for locations outside home."""
867
+ try:
868
+ return path.relative_to(env.home)
869
+ except ValueError:
870
+ return Path("external") / str(path).lstrip("/")
871
+
802
872
  def backup(path: Path) -> None:
803
873
  if str(path) in backed_up:
804
874
  return
805
- target = recovery / "files" / path.relative_to(env.home)
875
+ target = recovery / "files" / inside_recovery(path)
806
876
  target.parent.mkdir(parents=True, exist_ok=True)
807
877
  shutil.copy2(path, target)
808
878
  backed_up.add(str(path))
@@ -812,8 +882,8 @@ def retire_legacy(env: Env, artifacts: list[dict], recovery: Path) -> list[dict]
812
882
  if item.get("manual"):
813
883
  done.append({**item, "status": "manual"})
814
884
  continue
815
- if item["kind"] in {"plugin-copy", "plugin-record", "claude-skill-link"}:
816
- target = recovery / "moved" / path.relative_to(env.home)
885
+ if item["kind"] in {"plugin-copy", "plugin-record", "claude-skill-link", "codex-personal-cache"}:
886
+ target = recovery / "moved" / inside_recovery(path)
817
887
  target.parent.mkdir(parents=True, exist_ok=True)
818
888
  os.replace(path, target)
819
889
  done.append({**item, "status": "moved", "to": str(target)})
@@ -845,9 +915,15 @@ def recovery_dir_for(env: Env, label: str) -> Path:
845
915
  return env.home / ".agent-pack" / "recovery" / f"{stamp}-agenthelm-{label}"
846
916
 
847
917
 
848
- def locate(env: Env, plugin: str, version: str | None = None) -> dict:
849
- """Where a plugin is installed, newest first: the stable lookup for downstream wrappers."""
850
- found = [item for item in installed_plugins(env, {plugin}) if version is None or item.get("version") == version]
918
+ def locate(env: Env, plugin: str, version: str | None = None, *, include_legacy: bool = False) -> dict:
919
+ """Where a plugin is installed, newest first: the stable lookup for downstream wrappers.
920
+
921
+ Legacy copies are left out unless asked for, so a wrapper that works today
922
+ keeps working after ``migrate`` retires them.
923
+ """
924
+ found = [item for item in installed_plugins(env, {plugin})
925
+ if (version is None or item.get("version") == version)
926
+ and (include_legacy or item["client"] != "legacy")]
851
927
  found.sort(key=lambda item: (version_key(item.get("version") or "") or (0,), item["client"] != "legacy"),
852
928
  reverse=True)
853
929
  return {"ok": bool(found), "plugin": plugin, "version": version,
@@ -930,6 +1006,8 @@ def parser() -> argparse.ArgumentParser:
930
1006
  locate_parser = sub.add_parser("locate", help="输出插件的已安装位置(最新优先),供下游脚本查找")
931
1007
  locate_parser.add_argument("plugin")
932
1008
  locate_parser.add_argument("--version", help="只要这个版本")
1009
+ locate_parser.add_argument("--include-legacy", action="store_true",
1010
+ help="也列出旧方式安装(默认不列,避免清理后才失效)")
933
1011
  locate_parser.add_argument("--output", choices=("text", "json"), default="json")
934
1012
  locate_parser.add_argument("--home", type=Path, help=argparse.SUPPRESS)
935
1013
  return result
@@ -966,7 +1044,7 @@ def main(argv: list[str] | None = None) -> int:
966
1044
  if args.command == "check" and args.hook:
967
1045
  return hook(env, project, args.hook_format)
968
1046
  if args.command == "locate":
969
- found = locate(env, args.plugin, args.version)
1047
+ found = locate(env, args.plugin, args.version, include_legacy=args.include_legacy)
970
1048
  if args.output == "json":
971
1049
  print(json.dumps(found, ensure_ascii=False, indent=2))
972
1050
  elif found["path"]: