@agentlayer.tech/wallet 0.1.89 → 0.1.91

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.
Files changed (33) hide show
  1. package/.openclaw/extensions/agent-wallet/dist/index.js +5 -5
  2. package/.openclaw/extensions/agent-wallet/index.ts +5 -5
  3. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
  4. package/.openclaw/extensions/agent-wallet/package.json +1 -1
  5. package/CHANGELOG.md +14 -0
  6. package/README.md +46 -23
  7. package/VERSION +1 -1
  8. package/agent-wallet/README.md +26 -1
  9. package/agent-wallet/UPGRADE_COMPATIBILITY.md +2 -1
  10. package/agent-wallet/agent_wallet/__init__.py +1 -1
  11. package/agent-wallet/agent_wallet/autonomous_permissions.py +43 -1
  12. package/agent-wallet/agent_wallet/openclaw_adapter.py +604 -375
  13. package/agent-wallet/agent_wallet/providers/x402.py +84 -0
  14. package/agent-wallet/openclaw.plugin.json +1 -1
  15. package/agent-wallet/pyproject.toml +1 -1
  16. package/agent-wallet/scripts/install_agent_wallet.py +197 -11
  17. package/agent-wallet/scripts/install_openclaw_local_config.py +4 -1
  18. package/agent-wallet/scripts/install_openclaw_sealed_keys.py +6 -2
  19. package/bin/lib/host-detection.mjs +236 -0
  20. package/bin/lib/integrations.mjs +69 -5
  21. package/bin/openclaw-agent-wallet.mjs +398 -28
  22. package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
  23. package/claude-code/plugins/agent-wallet/README.md +3 -1
  24. package/claude-code/plugins/agent-wallet/commands/agentlayer-autonomous-approve.md +7 -8
  25. package/claude-code/plugins/agent-wallet/commands/agentlayer-autonomous-revoke.md +2 -2
  26. package/claude-code/plugins/agent-wallet/commands/wallet-base.md +38 -15
  27. package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
  28. package/codex/plugins/agent-wallet/README.md +11 -6
  29. package/codex/plugins/agent-wallet/skills/wallet-base/SKILL.md +56 -0
  30. package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
  31. package/package.json +8 -3
  32. package/wdk-btc-wallet/package.json +1 -1
  33. package/wdk-evm-wallet/package.json +1 -1
@@ -1600,6 +1600,90 @@ def _reusable_approved_preview(
1600
1600
  return dict(approved_preview)
1601
1601
 
1602
1602
 
1603
+ #: USD amount below which an x402 payment never requires host/session/
1604
+ #: permission approval, regardless of network (including mainnet) -- the
1605
+ #: same rationale as in-person card payments skipping a signature/PIN below
1606
+ #: a floor limit. Only ever applies when the payment asset is confidently
1607
+ #: identified as USDC (see _looks_like_usdc); any other asset always
1608
+ #: requires approval, since its USD value can't be determined here.
1609
+ DE_MINIMIS_USD_THRESHOLD = 2.0
1610
+
1611
+
1612
+ def de_minimis_usd_amount(preview: dict[str, Any]) -> float | None:
1613
+ """Return *preview*'s payment amount in USD, or None if it isn't confidently USDC.
1614
+
1615
+ ``x402_amount_display`` is only populated by normalize_payment_requirement
1616
+ when the asset is recognized as USDC (see _looks_like_usdc) -- for any
1617
+ other asset it's None, since its USD value is unknown here.
1618
+ """
1619
+ amount_display = preview.get("x402_amount_display")
1620
+ if not isinstance(amount_display, str) or not amount_display.strip():
1621
+ return None
1622
+ try:
1623
+ return float(amount_display)
1624
+ except ValueError:
1625
+ return None
1626
+
1627
+
1628
+ def is_de_minimis_payment(
1629
+ preview: dict[str, Any],
1630
+ *,
1631
+ threshold_usd: float | None = None,
1632
+ ) -> bool:
1633
+ """Whether *preview*'s payment is small enough to skip approval entirely.
1634
+
1635
+ threshold_usd defaults to the current DE_MINIMIS_USD_THRESHOLD, read at
1636
+ call time (not bound at import time) so tests can monkeypatch the module
1637
+ attribute directly.
1638
+ """
1639
+ usd_amount = de_minimis_usd_amount(preview)
1640
+ if usd_amount is None:
1641
+ return False
1642
+ effective_threshold = DE_MINIMIS_USD_THRESHOLD if threshold_usd is None else threshold_usd
1643
+ return usd_amount < effective_threshold
1644
+
1645
+
1646
+ async def resolve_payment_preview(
1647
+ *,
1648
+ backend: AgentWalletBackend,
1649
+ url: str,
1650
+ method: str = "GET",
1651
+ headers: dict[str, Any] | None = None,
1652
+ query: dict[str, Any] | None = None,
1653
+ json_body: Any | None = None,
1654
+ text_body: str | None = None,
1655
+ approved_preview: dict[str, Any] | None = None,
1656
+ ) -> dict[str, Any]:
1657
+ """Return the payment preview for this exact request.
1658
+
1659
+ Reuses *approved_preview* when it still matches (same fingerprint check
1660
+ ``pay_and_fetch`` applies before deciding whether to skip a fresh probe),
1661
+ otherwise makes a fresh unpaid probe. Callers that need a summary to bind
1662
+ an approval token to -- before paying -- can call this instead of
1663
+ duplicating the reuse-or-probe logic ``pay_and_fetch`` already has.
1664
+ """
1665
+ request = _build_request_metadata(
1666
+ url=url,
1667
+ method=method,
1668
+ headers=headers,
1669
+ query=query,
1670
+ json_body=json_body,
1671
+ text_body=text_body,
1672
+ )
1673
+ reused = _reusable_approved_preview(approved_preview, request=request)
1674
+ if reused is not None:
1675
+ return reused
1676
+ return await preview_request(
1677
+ backend=backend,
1678
+ url=url,
1679
+ method=method,
1680
+ headers=headers,
1681
+ query=query,
1682
+ json_body=json_body,
1683
+ text_body=text_body,
1684
+ )
1685
+
1686
+
1603
1687
  async def pay_and_fetch(
1604
1688
  *,
1605
1689
  backend: AgentWalletBackend,
@@ -2,7 +2,7 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Plugin-friendly wallet backend for OpenClaw agents with safe wallet tools and runtime instructions across Solana, local BTC, and local EVM.",
5
- "version": "0.1.89",
5
+ "version": "0.1.91",
6
6
  "skills": ["skills/wallet-operator"],
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openclaw-agent-wallet"
7
- version = "0.1.89"
7
+ version = "0.1.91"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -7,13 +7,20 @@ import hashlib
7
7
  import json
8
8
  import os
9
9
  import platform
10
+ import re
10
11
  import shutil
11
12
  import subprocess
12
13
  import sys
13
14
  import tempfile
15
+ import urllib.error
16
+ import urllib.request
14
17
  import venv
15
18
  from pathlib import Path
16
19
 
20
+ WELCOME_INVITE_PATTERN = re.compile(r"^alw_[A-Za-z0-9_-]{43}$")
21
+ DEFAULT_ONBOARDING_BIND_URL = "https://www.agent-layer.tech/api/onboarding/bind-wallet"
22
+ ONBOARDING_HTTP_TIMEOUT_SECONDS = 5.0
23
+
17
24
  INCLUDED_RUNTIME_ROOT_FILES = [
18
25
  ".env.example",
19
26
  "AGENTS.md",
@@ -192,9 +199,16 @@ def build_parser() -> argparse.ArgumentParser:
192
199
  parser.add_argument("--network", default="mainnet")
193
200
  parser.add_argument("--rpc-url", default="")
194
201
  parser.add_argument("--rpc-urls", default="")
202
+ parser.add_argument("--invite", default="")
195
203
  parser.add_argument("--sign-only", action=argparse.BooleanOptionalAction, default=False)
196
204
  parser.add_argument("--sync-runtime", action=argparse.BooleanOptionalAction, default=True)
197
205
  parser.add_argument("--install-from-runtime", action=argparse.BooleanOptionalAction, default=False)
206
+ parser.add_argument(
207
+ "--configure-openclaw",
208
+ action=argparse.BooleanOptionalAction,
209
+ default=True,
210
+ help="Patch openclaw.json during install (disabled by the universal host orchestrator).",
211
+ )
198
212
  parser.add_argument("--skip-python-setup", action=argparse.BooleanOptionalAction, default=False)
199
213
  parser.add_argument("--skip-node-setup", action=argparse.BooleanOptionalAction, default=False)
200
214
  parser.add_argument("--dry-run", action=argparse.BooleanOptionalAction, default=False)
@@ -856,6 +870,149 @@ def _bootstrap_evm_wallet(
856
870
  }
857
871
 
858
872
 
873
+ def _onboarding_bind_url() -> str:
874
+ return (
875
+ os.getenv("AGENTLAYER_ONBOARDING_BIND_URL", "").strip()
876
+ or DEFAULT_ONBOARDING_BIND_URL
877
+ )
878
+
879
+
880
+ def _onboarding_error_code(payload: object, fallback: str) -> str:
881
+ if not isinstance(payload, dict):
882
+ return fallback
883
+ code = str(payload.get("error") or "").strip().lower()
884
+ allowed = {
885
+ "address_already_used",
886
+ "internal_error",
887
+ "invalid_base_address",
888
+ "invalid_invite",
889
+ "invite_already_bound",
890
+ "invite_expired",
891
+ "invite_revoked",
892
+ "request_too_large",
893
+ }
894
+ return code if code in allowed else fallback
895
+
896
+
897
+ def _decode_onboarding_response(raw: bytes) -> object:
898
+ try:
899
+ return json.loads(raw.decode("utf-8"))
900
+ except (UnicodeDecodeError, json.JSONDecodeError):
901
+ return {}
902
+
903
+
904
+ def _bind_welcome_invite(
905
+ invite: str,
906
+ address: str,
907
+ *,
908
+ api_url: str | None = None,
909
+ timeout_seconds: float = ONBOARDING_HTTP_TIMEOUT_SECONDS,
910
+ opener: object = urllib.request.urlopen,
911
+ attempts: int = 2,
912
+ ) -> dict[str, object]:
913
+ normalized_invite = invite.strip()
914
+ normalized_address = address.strip()
915
+ if not WELCOME_INVITE_PATTERN.fullmatch(normalized_invite):
916
+ return {"ok": False, "status": "invalid_invite", "retryable": False}
917
+ if not re.fullmatch(r"0x[0-9a-fA-F]{40}", normalized_address):
918
+ return {"ok": False, "status": "invalid_base_address", "retryable": False}
919
+
920
+ body = json.dumps({"address": normalized_address}).encode("utf-8")
921
+ request = urllib.request.Request(
922
+ api_url or _onboarding_bind_url(),
923
+ data=body,
924
+ method="POST",
925
+ headers={
926
+ "Authorization": f"Bearer {normalized_invite}",
927
+ "Content-Type": "application/json",
928
+ "Accept": "application/json",
929
+ "User-Agent": "AgentLayer-Wallet-Installer",
930
+ },
931
+ )
932
+ max_attempts = max(1, min(int(attempts), 3))
933
+ for attempt in range(max_attempts):
934
+ try:
935
+ with opener(request, timeout=timeout_seconds) as response: # type: ignore[operator]
936
+ status_code = int(getattr(response, "status", 200))
937
+ payload = _decode_onboarding_response(response.read(65_536))
938
+ if status_code < 200 or status_code >= 300 or not isinstance(payload, dict):
939
+ if status_code >= 500 and attempt + 1 < max_attempts:
940
+ continue
941
+ return {
942
+ "ok": False,
943
+ "status": _onboarding_error_code(payload, "service_error"),
944
+ "retryable": status_code >= 500,
945
+ }
946
+ binding_status = str(payload.get("status") or "")
947
+ response_address = str(payload.get("address") or "")
948
+ if (
949
+ payload.get("ok") is not True
950
+ or binding_status not in {"bound", "already_bound"}
951
+ or response_address.lower() != normalized_address.lower()
952
+ ):
953
+ return {"ok": False, "status": "invalid_response", "retryable": True}
954
+ return {
955
+ "ok": True,
956
+ "status": binding_status,
957
+ "network": "base",
958
+ "address": response_address,
959
+ }
960
+ except urllib.error.HTTPError as exc:
961
+ payload = _decode_onboarding_response(exc.read(65_536))
962
+ if exc.code >= 500 and attempt + 1 < max_attempts:
963
+ continue
964
+ return {
965
+ "ok": False,
966
+ "status": _onboarding_error_code(payload, "service_error"),
967
+ "retryable": exc.code >= 500,
968
+ }
969
+ except (urllib.error.URLError, TimeoutError, OSError):
970
+ if attempt + 1 < max_attempts:
971
+ continue
972
+ return {"ok": False, "status": "network_error", "retryable": True}
973
+ return {"ok": False, "status": "network_error", "retryable": True}
974
+
975
+
976
+ def _bind_invite_after_evm_onboard(
977
+ invite: str,
978
+ evm_onboard_result: dict[str, object] | None,
979
+ *,
980
+ api_url: str | None = None,
981
+ opener: object = urllib.request.urlopen,
982
+ ) -> dict[str, object] | None:
983
+ if not invite.strip():
984
+ return None
985
+ if not isinstance(evm_onboard_result, dict) or not evm_onboard_result.get("ok"):
986
+ return {"ok": False, "status": "pending_evm_wallet", "retryable": True}
987
+ address = str(evm_onboard_result.get("address") or "").strip()
988
+ if not address:
989
+ return {"ok": False, "status": "pending_evm_wallet", "retryable": True}
990
+ return _bind_welcome_invite(
991
+ invite,
992
+ address,
993
+ api_url=api_url,
994
+ opener=opener,
995
+ )
996
+
997
+
998
+ def _invite_binding_warning(binding_result: dict[str, object]) -> str:
999
+ """Describe a failed invite bind without claiming it can always be retried."""
1000
+ status = str(binding_result.get("status") or "unknown")
1001
+ if status == "invite_already_bound":
1002
+ return (
1003
+ "warning: the welcome invite is already bound to a different Base "
1004
+ "wallet and cannot be used with this wallet. Status: "
1005
+ + status
1006
+ )
1007
+ if binding_result.get("retryable") is True:
1008
+ return (
1009
+ "warning: the welcome invite was not bound; the invite remains "
1010
+ "available for a safe retry. Status: "
1011
+ + status
1012
+ )
1013
+ return "warning: the welcome invite was not bound and cannot be retried. Status: " + status
1014
+
1015
+
859
1016
  def main() -> None:
860
1017
  args = build_parser().parse_args()
861
1018
  source_package_root = Path(args.package_root).expanduser().resolve()
@@ -909,6 +1066,7 @@ def main() -> None:
909
1066
  wdk_evm_root = source_wdk_evm_root
910
1067
 
911
1068
  install_config_script = package_root / "scripts" / "install_openclaw_local_config.py"
1069
+ install_sealed_keys_script = package_root / "scripts" / "install_openclaw_sealed_keys.py"
912
1070
  if args.install_from_runtime:
913
1071
  default_source_env_path = source_package_root / ".env"
914
1072
  default_source_venv_path = source_package_root / ".venv"
@@ -923,7 +1081,7 @@ def main() -> None:
923
1081
  env_created = _ensure_env_file(env_path, env_example_path)
924
1082
  boot_key_file_env_updated = _ensure_runtime_boot_key_file_env(env_path)
925
1083
  flash_bridge_env = _ensure_flash_bridge_env(env_path, package_root)
926
- config_created = _ensure_openclaw_config(config_path)
1084
+ config_created = _ensure_openclaw_config(config_path) if args.configure_openclaw else False
927
1085
 
928
1086
  python_bin = Path(sys.executable)
929
1087
  venv_created = False
@@ -988,23 +1146,33 @@ def main() -> None:
988
1146
  pending_env = _pending_env_names() if backend_enabled else []
989
1147
  configured = False
990
1148
  configure_stdout = ""
1149
+ sealed_keys_result: dict[str, object] | None = None
991
1150
  solana_onboard_result: dict[str, object] | None = None
992
1151
  evm_onboard_result: dict[str, object] | None = None
1152
+ invite_binding_result: dict[str, object] | None = None
993
1153
  if backend_enabled and not pending_env and not args.dry_run:
994
- result = subprocess.run(
995
- _build_next_steps(
996
- python_bin,
997
- install_config_script,
998
- args,
999
- package_root=package_root,
1000
- extension_path=extension_path,
1001
- ),
1154
+ sealed_result = subprocess.run(
1155
+ [str(python_bin), str(install_sealed_keys_script)],
1002
1156
  capture_output=True,
1003
1157
  text=True,
1004
1158
  check=True,
1005
1159
  )
1006
- configured = True
1007
- configure_stdout = result.stdout
1160
+ sealed_keys_result = json.loads(sealed_result.stdout)
1161
+ if args.configure_openclaw:
1162
+ result = subprocess.run(
1163
+ _build_next_steps(
1164
+ python_bin,
1165
+ install_config_script,
1166
+ args,
1167
+ package_root=package_root,
1168
+ extension_path=extension_path,
1169
+ ),
1170
+ capture_output=True,
1171
+ text=True,
1172
+ check=True,
1173
+ )
1174
+ configured = True
1175
+ configure_stdout = result.stdout
1008
1176
  solana_onboard_result = _bootstrap_solana_wallet(
1009
1177
  python_bin,
1010
1178
  package_root,
@@ -1027,6 +1195,21 @@ def main() -> None:
1027
1195
  file=sys.stderr,
1028
1196
  )
1029
1197
 
1198
+ if args.invite.strip():
1199
+ if args.dry_run:
1200
+ invite_binding_result = {
1201
+ "ok": False,
1202
+ "status": "skipped_dry_run",
1203
+ "retryable": True,
1204
+ }
1205
+ else:
1206
+ invite_binding_result = _bind_invite_after_evm_onboard(
1207
+ args.invite,
1208
+ evm_onboard_result,
1209
+ )
1210
+ if invite_binding_result and not invite_binding_result.get("ok"):
1211
+ print(_invite_binding_warning(invite_binding_result), file=sys.stderr)
1212
+
1030
1213
  print(
1031
1214
  json.dumps(
1032
1215
  {
@@ -1037,6 +1220,7 @@ def main() -> None:
1037
1220
  "flash_bridge_env": flash_bridge_env,
1038
1221
  "config_path": str(config_path),
1039
1222
  "config_created": config_created,
1223
+ "configure_openclaw": bool(args.configure_openclaw),
1040
1224
  "package_root": str(package_root),
1041
1225
  "extension_path": str(extension_path),
1042
1226
  "wdk_btc_root": str(wdk_btc_root),
@@ -1051,8 +1235,10 @@ def main() -> None:
1051
1235
  "runtime_sync": runtime_sync,
1052
1236
  "configured": configured,
1053
1237
  "pending_env": pending_env,
1238
+ "sealed_keys": sealed_keys_result,
1054
1239
  "solana_wallet": solana_onboard_result,
1055
1240
  "evm_wallet": evm_onboard_result,
1241
+ "invite_binding": invite_binding_result,
1056
1242
  "next_configure_command": _build_next_steps(
1057
1243
  python_bin,
1058
1244
  install_config_script,
@@ -271,7 +271,10 @@ def _maybe_install_sealed_keys() -> str | None:
271
271
  updates["wdk_evm_wallet_password"] = secrets.token_urlsafe(24)
272
272
  if not updates:
273
273
  return None
274
- return str(seal_keys(boot_key, {**existing, **updates}))
274
+ merged = {**existing, **updates}
275
+ if merged == existing:
276
+ return str(sealed_path)
277
+ return str(seal_keys(boot_key, merged))
275
278
 
276
279
 
277
280
  def _require_hardened_runtime_secrets(backend: str) -> str | None:
@@ -94,15 +94,19 @@ def main() -> None:
94
94
  "and/or SOLANA_AGENT_PRIVATE_KEY in the environment."
95
95
  )
96
96
 
97
- path = seal_keys(boot_key, secrets)
97
+ changed = not sealed_path.exists() or secrets != existing
98
+ path = seal_keys(boot_key, secrets) if changed else sealed_path
98
99
  print(
99
100
  json.dumps(
100
101
  {
101
102
  "ok": True,
102
103
  "path": str(path),
103
104
  "stored_keys": sorted(secrets.keys()),
104
- "updated_keys": sorted(set(updates.keys()) | set(generated_keys)),
105
+ "updated_keys": (
106
+ sorted(set(updates.keys()) | set(generated_keys)) if changed else []
107
+ ),
105
108
  "replaced": bool(args.replace),
109
+ "changed": changed,
106
110
  },
107
111
  indent=2,
108
112
  )
@@ -0,0 +1,236 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const HOST_NAMES = Object.freeze([
6
+ "openclaw",
7
+ "codex",
8
+ "claude-code",
9
+ "hermes",
10
+ ]);
11
+
12
+ const HOST_ALIASES = Object.freeze({
13
+ openclaw: "openclaw",
14
+ codex: "codex",
15
+ claude: "claude-code",
16
+ "claude-code": "claude-code",
17
+ claudecode: "claude-code",
18
+ hermes: "hermes",
19
+ });
20
+
21
+ function expandHome(value, env) {
22
+ const home = env.HOME || os.homedir();
23
+ if (value === "~") return home;
24
+ if (value.startsWith("~/")) return path.join(home, value.slice(2));
25
+ return value;
26
+ }
27
+
28
+ function existingEvidence(candidates, exists = fs.existsSync) {
29
+ return candidates.filter((candidate) => {
30
+ try {
31
+ return exists(candidate);
32
+ } catch {
33
+ return false;
34
+ }
35
+ });
36
+ }
37
+
38
+ function detectedHost(name, displayName, binary, configCandidates, commandPath, exists) {
39
+ const binaryPath = commandPath(binary);
40
+ const configPaths = existingEvidence(configCandidates, exists);
41
+ const evidence = [
42
+ ...(binaryPath ? [{ type: "cli", path: binaryPath }] : []),
43
+ ...configPaths.map((configPath) => ({ type: "config", path: configPath })),
44
+ ];
45
+ return {
46
+ name,
47
+ display_name: displayName,
48
+ detected: evidence.length > 0,
49
+ confidence: evidence.length > 0 ? "high" : "none",
50
+ evidence,
51
+ };
52
+ }
53
+
54
+ export function detectHosts({
55
+ env = process.env,
56
+ commandPath,
57
+ exists = fs.existsSync,
58
+ } = {}) {
59
+ if (typeof commandPath !== "function") {
60
+ throw new TypeError("detectHosts requires commandPath(name)");
61
+ }
62
+ const home = env.HOME || os.homedir();
63
+ const openclawHome = path.resolve(expandHome(env.OPENCLAW_HOME || "~/.openclaw", env));
64
+ const codexHome = path.resolve(expandHome(env.CODEX_HOME || "~/.codex", env));
65
+ const hermesHome = path.resolve(expandHome(env.HERMES_HOME || "~/.hermes", env));
66
+ const claudeHome = path.resolve(expandHome(env.CLAUDE_CONFIG_DIR || "~/.claude", env));
67
+
68
+ return [
69
+ detectedHost(
70
+ "openclaw",
71
+ "OpenClaw",
72
+ "openclaw",
73
+ [path.join(openclawHome, "openclaw.json")],
74
+ commandPath,
75
+ exists,
76
+ ),
77
+ detectedHost(
78
+ "codex",
79
+ "Codex",
80
+ "codex",
81
+ [path.join(codexHome, "config.toml")],
82
+ commandPath,
83
+ exists,
84
+ ),
85
+ detectedHost(
86
+ "claude-code",
87
+ "Claude Code",
88
+ "claude",
89
+ [
90
+ path.join(claudeHome, "settings.json"),
91
+ path.join(home, ".claude.json"),
92
+ ],
93
+ commandPath,
94
+ exists,
95
+ ),
96
+ detectedHost(
97
+ "hermes",
98
+ "Hermes",
99
+ "hermes",
100
+ [
101
+ path.join(hermesHome, "config.yaml"),
102
+ path.join(hermesHome, "config.yml"),
103
+ path.join(hermesHome, "settings.json"),
104
+ ],
105
+ commandPath,
106
+ exists,
107
+ ),
108
+ ];
109
+ }
110
+
111
+ function flagValues(args, name) {
112
+ const values = [];
113
+ const prefix = `${name}=`;
114
+ for (let index = 0; index < args.length; index += 1) {
115
+ const value = args[index];
116
+ if (value === name) {
117
+ const next = args[index + 1] || "";
118
+ if (!next || next.startsWith("--")) {
119
+ throw new Error(`${name} requires a value.`);
120
+ }
121
+ values.push(next);
122
+ index += 1;
123
+ } else if (value.startsWith(prefix)) {
124
+ values.push(value.slice(prefix.length));
125
+ }
126
+ }
127
+ return values;
128
+ }
129
+
130
+ function normalizeHostToken(value) {
131
+ const normalized = String(value || "").trim().toLowerCase();
132
+ return HOST_ALIASES[normalized] || normalized;
133
+ }
134
+
135
+ function parseHostSet(values, { detected, managed }) {
136
+ const selected = new Set();
137
+ for (const rawValue of values) {
138
+ for (const rawToken of String(rawValue).split(",")) {
139
+ const token = normalizeHostToken(rawToken);
140
+ if (!token) continue;
141
+ if (token === "none" || token === "runtime-only") {
142
+ selected.clear();
143
+ continue;
144
+ }
145
+ if (token === "all") {
146
+ HOST_NAMES.forEach((name) => selected.add(name));
147
+ continue;
148
+ }
149
+ if (token === "detected") {
150
+ detected.forEach((name) => selected.add(name));
151
+ continue;
152
+ }
153
+ if (token === "managed") {
154
+ managed.forEach((name) => selected.add(name));
155
+ continue;
156
+ }
157
+ if (!HOST_NAMES.includes(token)) {
158
+ throw new Error(
159
+ `Unknown host '${rawToken}'. Expected: ${HOST_NAMES.join(", ")}, detected, managed, all, or none.`,
160
+ );
161
+ }
162
+ selected.add(token);
163
+ }
164
+ }
165
+ return selected;
166
+ }
167
+
168
+ export function buildInstallPlan({
169
+ args,
170
+ detections,
171
+ managedHosts = [],
172
+ runtimeInstalled,
173
+ }) {
174
+ const detected = detections.filter((entry) => entry.detected).map((entry) => entry.name);
175
+ const managed = managedHosts.filter((name) => HOST_NAMES.includes(name));
176
+ const hostValues = flagValues(args, "--hosts");
177
+ const explicitHosts = hostValues.length > 0;
178
+ const runtimeOnly = args.includes("--runtime-only");
179
+ const managedOnly = args.includes("--managed-only");
180
+
181
+ let selected;
182
+ let selectionReason;
183
+ if (runtimeOnly) {
184
+ selected = new Set();
185
+ selectionReason = "runtime_only";
186
+ } else if (managedOnly) {
187
+ selected = new Set(managed);
188
+ selectionReason = "managed_only";
189
+ } else if (explicitHosts) {
190
+ selected = parseHostSet(hostValues, { detected, managed });
191
+ selectionReason = "explicit";
192
+ } else if (runtimeInstalled) {
193
+ selected = new Set(managed);
194
+ selectionReason = "existing_runtime_managed_only";
195
+ } else {
196
+ selected = new Set(detected);
197
+ selectionReason = "fresh_install_detected";
198
+ }
199
+
200
+ const excluded = parseHostSet(flagValues(args, "--exclude"), { detected, managed });
201
+ excluded.forEach((name) => selected.delete(name));
202
+
203
+ return {
204
+ schema_version: 1,
205
+ runtime_installed_before: Boolean(runtimeInstalled),
206
+ selection_reason: selectionReason,
207
+ explicit_hosts: explicitHosts,
208
+ detected_hosts: detected,
209
+ managed_hosts: managed,
210
+ selected_hosts: HOST_NAMES.filter((name) => selected.has(name)),
211
+ excluded_hosts: HOST_NAMES.filter((name) => excluded.has(name)),
212
+ detections,
213
+ };
214
+ }
215
+
216
+ export function stripUniversalInstallerArgs(args) {
217
+ const output = [];
218
+ const valueFlags = new Set(["--hosts", "--exclude"]);
219
+ const booleanFlags = new Set([
220
+ "--runtime-only",
221
+ "--managed-only",
222
+ "--no-prompt",
223
+ "--json",
224
+ ]);
225
+ for (let index = 0; index < args.length; index += 1) {
226
+ const value = args[index];
227
+ if (booleanFlags.has(value)) continue;
228
+ if (valueFlags.has(value)) {
229
+ index += 1;
230
+ continue;
231
+ }
232
+ if ([...valueFlags].some((name) => value.startsWith(`${name}=`))) continue;
233
+ output.push(value);
234
+ }
235
+ return output;
236
+ }