@sakanano-moe/agenthelm 6.5.0 → 6.5.2

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.0",
3
+ "version": "6.5.2",
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
@@ -331,7 +336,8 @@ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
331
336
  except (OSError, ValueError, KeyError, TypeError) as exc:
332
337
  return [{"status": "error", "error": f"{OPINION_LOCK} 无法解析:{exc}"}]
333
338
  with tempfile.TemporaryDirectory() as temporary:
334
- profile_file = Path(temporary) / "profile.json"
339
+ # macOS temporary paths live under /var, a symlink opinion-manager refuses.
340
+ profile_file = Path(temporary).resolve() / "profile.json"
335
341
  profile_file.write_text(json.dumps(profile, ensure_ascii=False), encoding="utf-8")
336
342
  completed = subprocess.run(
337
343
  [sys.executable, str(manager / "runtime" / "opinion_manager.py"), "updates",
@@ -449,7 +455,10 @@ def render_text(report: dict) -> str:
449
455
  lines.append(f" [Opinion] 有新版本:{refs}(用 opinion-manager 预览后确认)")
450
456
  elif item["status"] in {"error", "unknown"}:
451
457
  lines.append(f" [Opinion] {item.get('error')}")
452
- lines.append(one_line(report) or "全部为最新。")
458
+ if not report["plugins"]:
459
+ lines.append(f"运行 `{CLI} install` 预览安装。")
460
+ else:
461
+ lines.append(one_line(report) or "全部为最新。")
453
462
  return "\n".join(lines) + "\n"
454
463
 
455
464
 
@@ -520,7 +529,29 @@ def _safe_extract(archive: Path, destination: Path) -> None:
520
529
  raise CheckError(f"发布包含越界路径:{member.name}")
521
530
  if not (member.isfile() or member.isdir()):
522
531
  raise CheckError(f"发布包含不支持的条目:{member.name}")
523
- bundle.extractall(destination) # noqa: S202 - every member was validated above
532
+ try:
533
+ bundle.extractall(destination, filter="data") # noqa: S202 - members validated above
534
+ except TypeError: # Python without extraction filters
535
+ bundle.extractall(destination) # noqa: S202
536
+
537
+
538
+ def _plugin_versions(latest: dict) -> dict:
539
+ return {name: item.get("version") for name, item in latest.get("plugins", {}).items()}
540
+
541
+
542
+ def github_release(latest: dict) -> dict:
543
+ """Release details straight from GitHub, used when latest.json came from the raw fallback.
544
+
545
+ Accepted only when that tag ships exactly the plugin versions ``latest`` lists,
546
+ the same rule the site applies before attaching a release.
547
+ """
548
+ try:
549
+ slug = github_slug(latest)
550
+ release = _fetch_json(f"https://github.com/{slug}/releases/latest/download/release.json")
551
+ tagged = _fetch_json(f"https://raw.githubusercontent.com/{slug}/{release['tag']}/latest.json")
552
+ except (OSError, ValueError, KeyError, TypeError, CheckError, urllib.error.URLError):
553
+ return {}
554
+ return release if _plugin_versions(tagged) == _plugin_versions(latest) else {}
524
555
 
525
556
 
526
557
  def release_source(latest: dict, workdir: Path, source: Path | None = None) -> Path:
@@ -530,7 +561,7 @@ def release_source(latest: dict, workdir: Path, source: Path | None = None) -> P
530
561
  if not (root / "plugins").is_dir():
531
562
  raise CheckError(f"{root} 不含 plugins/ 目录")
532
563
  return root
533
- release = latest.get("release") or {}
564
+ release = latest.get("release") or github_release(latest)
534
565
  tag, digest = release.get("tag"), release.get("tarball_sha256")
535
566
  if not tag or not digest:
536
567
  raise CheckError("云端 latest.json 没有发布 tag 与摘要;请用 --source 指定本地仓库")
@@ -842,9 +873,15 @@ def recovery_dir_for(env: Env, label: str) -> Path:
842
873
  return env.home / ".agent-pack" / "recovery" / f"{stamp}-agenthelm-{label}"
843
874
 
844
875
 
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]
876
+ def locate(env: Env, plugin: str, version: str | None = None, *, include_legacy: bool = False) -> dict:
877
+ """Where a plugin is installed, newest first: the stable lookup for downstream wrappers.
878
+
879
+ Legacy copies are left out unless asked for, so a wrapper that works today
880
+ keeps working after ``migrate`` retires them.
881
+ """
882
+ found = [item for item in installed_plugins(env, {plugin})
883
+ if (version is None or item.get("version") == version)
884
+ and (include_legacy or item["client"] != "legacy")]
848
885
  found.sort(key=lambda item: (version_key(item.get("version") or "") or (0,), item["client"] != "legacy"),
849
886
  reverse=True)
850
887
  return {"ok": bool(found), "plugin": plugin, "version": version,
@@ -927,6 +964,8 @@ def parser() -> argparse.ArgumentParser:
927
964
  locate_parser = sub.add_parser("locate", help="输出插件的已安装位置(最新优先),供下游脚本查找")
928
965
  locate_parser.add_argument("plugin")
929
966
  locate_parser.add_argument("--version", help="只要这个版本")
967
+ locate_parser.add_argument("--include-legacy", action="store_true",
968
+ help="也列出旧方式安装(默认不列,避免清理后才失效)")
930
969
  locate_parser.add_argument("--output", choices=("text", "json"), default="json")
931
970
  locate_parser.add_argument("--home", type=Path, help=argparse.SUPPRESS)
932
971
  return result
@@ -963,7 +1002,7 @@ def main(argv: list[str] | None = None) -> int:
963
1002
  if args.command == "check" and args.hook:
964
1003
  return hook(env, project, args.hook_format)
965
1004
  if args.command == "locate":
966
- found = locate(env, args.plugin, args.version)
1005
+ found = locate(env, args.plugin, args.version, include_legacy=args.include_legacy)
967
1006
  if args.output == "json":
968
1007
  print(json.dumps(found, ensure_ascii=False, indent=2))
969
1008
  elif found["path"]: