@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 +1 -1
- package/runtime/agent_pack_config.py +30 -0
- package/runtime/agenthelm.py +50 -11
package/package.json
CHANGED
|
@@ -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():
|
package/runtime/agenthelm.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]:
|