agentapprove 0.1.21 → 0.1.22

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 (2) hide show
  1. package/dist/cli.js +593 -14
  2. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -2313,9 +2313,9 @@ var source_default = chalk;
2313
2313
 
2314
2314
  // src/cli.ts
2315
2315
  var import_qrcode_terminal = __toESM(require_main(), 1);
2316
- import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, readdirSync as readdirSync2, statSync, lstatSync, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs";
2317
- import { homedir, hostname, platform } from "os";
2318
- import { join, dirname as dirname2 } from "path";
2316
+ import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, readdirSync as readdirSync2, statSync, lstatSync, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs";
2317
+ import { homedir, hostname, platform, tmpdir } from "os";
2318
+ import { join, dirname as dirname2, sep as sep2 } from "path";
2319
2319
  import { execSync, spawnSync } from "child_process";
2320
2320
  import { randomBytes, createHash } from "crypto";
2321
2321
 
@@ -2827,7 +2827,7 @@ function resolvePairingApiBaseUrl(options) {
2827
2827
  }
2828
2828
 
2829
2829
  // src/cli.ts
2830
- var VERSION = "0.1.21";
2830
+ var VERSION = "0.1.22";
2831
2831
  function getApiUrl() {
2832
2832
  return process.env.AGENTAPPROVE_API || "https://api.agentapprove.com";
2833
2833
  }
@@ -2903,9 +2903,11 @@ var OPENCODE_PLUGIN_VERSION = "0.1.18";
2903
2903
  var OPENCODE_PLUGIN_SPEC = `@agentapprove/opencode@${OPENCODE_PLUGIN_VERSION}`;
2904
2904
  var OPENCLAW_PLUGIN_VERSION = "0.2.11";
2905
2905
  var OPENCLAW_PLUGIN_SPEC = `@agentapprove/openclaw@${OPENCLAW_PLUGIN_VERSION}`;
2906
- var PI_PLUGIN_VERSION = "0.1.6";
2906
+ var PI_PLUGIN_VERSION = "0.1.7";
2907
2907
  var PI_PLUGIN_SPEC = `npm:@agentapprove/pi@${PI_PLUGIN_VERSION}`;
2908
2908
  var PI_STATUS_TIMEOUT_MS = 5000;
2909
+ var HERMES_PLUGIN_VERSION = "0.1.0";
2910
+ var HERMES_PIP_DEPS = ["cryptography>=44.0,<46", "httpx>=0.27,<0.29"];
2909
2911
  var AGENTS = {
2910
2912
  "claude-code": {
2911
2913
  name: "Claude Code",
@@ -2944,6 +2946,23 @@ var AGENTS = {
2944
2946
  { name: "afterAgentResponse", file: "cursor-response.sh", description: "Agent response" }
2945
2947
  ]
2946
2948
  },
2949
+ windsurf: {
2950
+ name: "Windsurf",
2951
+ configPath: join(homedir(), ".codeium", "windsurf", "hooks.json"),
2952
+ hooksKey: "hooks",
2953
+ hooks: [
2954
+ { name: "pre_user_prompt", file: "windsurf-hook.sh", description: "User prompt (logging only)" },
2955
+ { name: "pre_read_code", file: "windsurf-hook.sh", description: "File read approval", isApprovalHook: true },
2956
+ { name: "post_read_code", file: "windsurf-hook.sh", description: "File read completion logging" },
2957
+ { name: "pre_write_code", file: "windsurf-hook.sh", description: "File write approval", isApprovalHook: true },
2958
+ { name: "post_write_code", file: "windsurf-hook.sh", description: "File write completion logging" },
2959
+ { name: "pre_run_command", file: "windsurf-hook.sh", description: "Shell command approval", isApprovalHook: true },
2960
+ { name: "post_run_command", file: "windsurf-hook.sh", description: "Shell command completion logging" },
2961
+ { name: "pre_mcp_tool_use", file: "windsurf-hook.sh", description: "MCP tool approval", isApprovalHook: true },
2962
+ { name: "post_mcp_tool_use", file: "windsurf-hook.sh", description: "MCP tool completion logging" },
2963
+ { name: "post_cascade_response", file: "windsurf-hook.sh", description: "Cascade response (logging)" }
2964
+ ]
2965
+ },
2947
2966
  "gemini-cli": {
2948
2967
  name: "Gemini CLI",
2949
2968
  configPath: join(homedir(), ".gemini", "settings.json"),
@@ -3023,6 +3042,27 @@ var AGENTS = {
3023
3042
  hooks: [
3024
3043
  { name: "agentapprove", file: "@agentapprove/pi", description: "Tool approval + event monitoring", isApprovalHook: true, isPlugin: true }
3025
3044
  ]
3045
+ },
3046
+ hermes: {
3047
+ name: "Hermes",
3048
+ configPath: join(homedir(), ".hermes", "config.yaml"),
3049
+ hooksKey: "plugins",
3050
+ hooks: [
3051
+ { name: "agentapprove", file: "agentapprove", description: "Tool approval + event monitoring + follow-up input", isApprovalHook: true, isPlugin: true }
3052
+ ]
3053
+ },
3054
+ openhands: {
3055
+ name: "OpenHands",
3056
+ configPath: join(homedir(), ".openhands", "hooks.json"),
3057
+ hooksKey: "",
3058
+ hooks: [
3059
+ { name: "session_start", file: "openhands-hook.sh", description: "Session started" },
3060
+ { name: "user_prompt_submit", file: "openhands-hook.sh", description: "User prompt (logging only)" },
3061
+ { name: "pre_tool_use", file: "openhands-hook.sh", description: "Tool approval", isApprovalHook: true, timeout: 300 },
3062
+ { name: "post_tool_use", file: "openhands-hook.sh", description: "Tool completion logging" },
3063
+ { name: "stop", file: "openhands-hook.sh", description: "Agent stopped (iOS input)", isApprovalHook: true, timeout: 600 },
3064
+ { name: "session_end", file: "openhands-hook.sh", description: "Session ended" }
3065
+ ]
3026
3066
  }
3027
3067
  };
3028
3068
  var SHARED_HOOK_FILES = ["common.sh"];
@@ -3070,7 +3110,24 @@ var HOOK_STATUS_LABELS = {
3070
3110
  userPromptSubmitted: "Prompt submitted",
3071
3111
  errorOccurred: "Error",
3072
3112
  SubagentStart: "Subagent start",
3073
- SubagentStop: "Subagent stop"
3113
+ SubagentStop: "Subagent stop",
3114
+ pre_user_prompt: "User prompt",
3115
+ pre_read_code: "File read approval",
3116
+ post_read_code: "File read completed",
3117
+ pre_write_code: "File write approval",
3118
+ post_write_code: "File write completed",
3119
+ pre_run_command: "Shell approval",
3120
+ post_run_command: "Shell completed",
3121
+ pre_mcp_tool_use: "MCP approval",
3122
+ post_mcp_tool_use: "MCP completed",
3123
+ post_cascade_response: "Cascade response",
3124
+ pre_tool_call: "Tool approval",
3125
+ post_tool_call: "Tool completed",
3126
+ pre_llm_call: "Prompt submitted",
3127
+ post_llm_call: "Stop",
3128
+ on_session_start: "Session start",
3129
+ on_session_end: "Session end",
3130
+ subagent_stop: "Subagent stop"
3074
3131
  };
3075
3132
  function findGitBash() {
3076
3133
  if (!isWindows())
@@ -3269,6 +3326,15 @@ function detectInstalledAgents() {
3269
3326
  installed.push(id);
3270
3327
  } catch {}
3271
3328
  }
3329
+ } else if (id === "hermes") {
3330
+ if (existsSync2(join(homedir(), ".hermes"))) {
3331
+ installed.push(id);
3332
+ } else {
3333
+ try {
3334
+ execSync("hermes --version", { stdio: "ignore" });
3335
+ installed.push(id);
3336
+ } catch {}
3337
+ }
3272
3338
  } else {
3273
3339
  const configDir = dirname2(agent.configPath);
3274
3340
  if (existsSync2(configDir)) {
@@ -4107,12 +4173,27 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4107
4173
  }
4108
4174
  return { success: true, backupPath: null, hooks: [installResult.label] };
4109
4175
  }
4176
+ if (agentId === "hermes") {
4177
+ if (mode === "observe") {
4178
+ const disableResult = disableHermesPluginViaCli();
4179
+ if (!disableResult.success) {
4180
+ return { success: false, backupPath: null, hooks: [], error: disableResult.error };
4181
+ }
4182
+ return { success: true, backupPath: null, hooks: [] };
4183
+ }
4184
+ const installResult = await installHermesPluginViaCli();
4185
+ if (!installResult.success) {
4186
+ return { success: false, backupPath: null, hooks: [], error: installResult.error };
4187
+ }
4188
+ return { success: true, backupPath: null, hooks: [installResult.label] };
4189
+ }
4110
4190
  const backupPath = backupConfig(agent.configPath);
4111
4191
  const config = readJsonConfig(agent.configPath);
4112
- if (!config[agent.hooksKey]) {
4192
+ const useTopLevelHooks = agent.hooksKey === "";
4193
+ if (!useTopLevelHooks && !config[agent.hooksKey]) {
4113
4194
  config[agent.hooksKey] = {};
4114
4195
  }
4115
- const hooksConfig = config[agent.hooksKey];
4196
+ const hooksConfig = useTopLevelHooks ? config : config[agent.hooksKey];
4116
4197
  const installedHooks = [];
4117
4198
  if (agentId === "openclaw") {
4118
4199
  const installResult = installOpenClawPluginViaCli();
@@ -4292,6 +4373,19 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4292
4373
  }
4293
4374
  }
4294
4375
  installedHooks.push(hook.name);
4376
+ } else if (agentId === "windsurf") {
4377
+ const existing = hooksConfig[hook.name];
4378
+ const existingArray = Array.isArray(existing) ? existing : existing && typeof existing === "object" ? [existing] : typeof existing === "string" ? [{ command: existing }] : [];
4379
+ const cleanedArray = existingArray.filter((h2) => {
4380
+ const command = h2.command || h2.powershell || "";
4381
+ return !command.includes("agentapprove");
4382
+ });
4383
+ cleanedArray.push({
4384
+ command: hookCommand,
4385
+ show_output: false
4386
+ });
4387
+ hooksConfig[hook.name] = cleanedArray;
4388
+ installedHooks.push(hook.name);
4295
4389
  } else if (agentId === "gemini-cli") {
4296
4390
  if (!hooksConfig[hook.name]) {
4297
4391
  hooksConfig[hook.name] = [];
@@ -4340,6 +4434,41 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4340
4434
  cleanedArray.push(hookEntry);
4341
4435
  hooksConfig[hook.name] = cleanedArray;
4342
4436
  installedHooks.push(hook.name);
4437
+ } else if (agentId === "openhands") {
4438
+ if (!hooksConfig[hook.name]) {
4439
+ hooksConfig[hook.name] = [];
4440
+ }
4441
+ const hookArray = hooksConfig[hook.name];
4442
+ const hookTimeout = hook.timeout;
4443
+ const isApproval = hook.isApprovalHook;
4444
+ const existingIdx = hookArray.findIndex((h2) => h2.hooks?.some((hookScript) => {
4445
+ if (typeof hookScript === "string")
4446
+ return hookScript.includes("agentapprove");
4447
+ if (typeof hookScript === "object" && hookScript.command)
4448
+ return hookScript.command.includes("agentapprove");
4449
+ return false;
4450
+ }));
4451
+ const hookObject = {
4452
+ type: "command",
4453
+ command: hookCommand
4454
+ };
4455
+ if (isApproval === true || hookTimeout !== undefined) {
4456
+ hookObject.timeout = hookTimeout ?? 300;
4457
+ hookObject.async = false;
4458
+ } else {
4459
+ hookObject.timeout = 30;
4460
+ hookObject.async = true;
4461
+ }
4462
+ const hookEntry = {
4463
+ matcher: "*",
4464
+ hooks: [hookObject]
4465
+ };
4466
+ if (existingIdx >= 0) {
4467
+ hookArray[existingIdx] = hookEntry;
4468
+ } else {
4469
+ hookArray.push(hookEntry);
4470
+ }
4471
+ installedHooks.push(hook.name);
4343
4472
  } else if (agentId === "copilot-cli") {
4344
4473
  if (typeof config["version"] !== "number") {
4345
4474
  config["version"] = 1;
@@ -4372,7 +4501,7 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4372
4501
  const approvalHooks = agent.hooks.filter((h2) => h2.isApprovalHook);
4373
4502
  for (const hook of approvalHooks) {
4374
4503
  if (hooksConfig[hook.name]) {
4375
- if (agentId === "claude-code" || agentId === "gemini-cli" || agentId === "codex") {
4504
+ if (agentId === "claude-code" || agentId === "gemini-cli" || agentId === "codex" || agentId === "openhands") {
4376
4505
  const hookArray = hooksConfig[hook.name];
4377
4506
  if (Array.isArray(hookArray)) {
4378
4507
  const cleaned = hookArray.filter((h2) => {
@@ -4433,6 +4562,31 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4433
4562
  hooksConfig[hook.name] = cleaned;
4434
4563
  }
4435
4564
  }
4565
+ } else if (agentId === "windsurf") {
4566
+ const existing = hooksConfig[hook.name];
4567
+ if (Array.isArray(existing)) {
4568
+ const cleaned = existing.filter((h2) => {
4569
+ if (typeof h2 === "object" && h2 !== null) {
4570
+ const cmd = h2.command || h2.powershell || "";
4571
+ return !cmd.includes("agentapprove");
4572
+ }
4573
+ if (typeof h2 === "string")
4574
+ return !h2.includes("agentapprove");
4575
+ return true;
4576
+ });
4577
+ if (cleaned.length === 0) {
4578
+ delete hooksConfig[hook.name];
4579
+ } else {
4580
+ hooksConfig[hook.name] = cleaned;
4581
+ }
4582
+ } else if (typeof existing === "object" && existing !== null) {
4583
+ const cmd = existing.command || existing.powershell || "";
4584
+ if (cmd.includes("agentapprove")) {
4585
+ delete hooksConfig[hook.name];
4586
+ }
4587
+ } else if (typeof existing === "string" && existing.includes("agentapprove")) {
4588
+ delete hooksConfig[hook.name];
4589
+ }
4436
4590
  } else if (agentId === "openclaw") {
4437
4591
  const pluginsObj = hooksConfig;
4438
4592
  const entries = pluginsObj?.entries;
@@ -4510,6 +4664,14 @@ codex_hooks = true`;
4510
4664
  hooksObj[hook.name] = [entry];
4511
4665
  }
4512
4666
  return JSON.stringify({ version: 1, hooks: hooksObj }, null, 2);
4667
+ } else if (agentId === "windsurf") {
4668
+ const hooksObj = {};
4669
+ for (const hook of agent.hooks) {
4670
+ const hookPath = join(hooksDir, hook.file);
4671
+ const hookCmd = buildHookCommand(hookPath, agentId);
4672
+ hooksObj[hook.name] = [{ command: hookCmd, show_output: false }];
4673
+ }
4674
+ return JSON.stringify({ hooks: hooksObj }, null, 2);
4513
4675
  } else if (agentId === "gemini-cli") {
4514
4676
  const hooksObj = {};
4515
4677
  for (const hook of agent.hooks) {
@@ -4557,6 +4719,23 @@ codex_hooks = true`;
4557
4719
  hooksObj[hook.name] = [entry];
4558
4720
  }
4559
4721
  return JSON.stringify({ version: 1, hooks: hooksObj }, null, 2);
4722
+ } else if (agentId === "openhands") {
4723
+ const hooksObj = {};
4724
+ for (const hook of agent.hooks) {
4725
+ const hookPath = join(hooksDir, hook.file);
4726
+ const hookCmd = buildHookCommand(hookPath, agentId);
4727
+ const hookTimeout = hook.timeout;
4728
+ const isApproval = hook.isApprovalHook;
4729
+ const blocking = isApproval === true || hookTimeout !== undefined;
4730
+ const hookObject = {
4731
+ type: "command",
4732
+ command: hookCmd,
4733
+ timeout: blocking ? hookTimeout ?? 300 : 30,
4734
+ async: !blocking
4735
+ };
4736
+ hooksObj[hook.name] = [{ matcher: "*", hooks: [hookObject] }];
4737
+ }
4738
+ return JSON.stringify(hooksObj, null, 2);
4560
4739
  } else if (agentId === "openclaw") {
4561
4740
  const configJson = JSON.stringify({
4562
4741
  plugins: {
@@ -4620,6 +4799,25 @@ codex_hooks = true`;
4620
4799
  "",
4621
4800
  "Restart Pi to activate the extension."
4622
4801
  ].join(`
4802
+ `);
4803
+ } else if (agentId === "hermes") {
4804
+ return [
4805
+ "Install the Agent Approve plugin for Hermes:",
4806
+ "",
4807
+ " npx agentapprove # automatic: download + verify + extract + enable",
4808
+ "",
4809
+ `Manual: download ${HERMES_BUNDLE_FILENAME} from your Agent Approve API,`,
4810
+ `verify SHA-256 matches ${HERMES_BUNDLE_SHA256.slice(0, 16)}…, extract into`,
4811
+ `~/.hermes/plugins/agentapprove/, write the SHA-256 to a .bundle-hash sidecar`,
4812
+ "in that directory, then:",
4813
+ "",
4814
+ " pip install --user cryptography httpx",
4815
+ " hermes plugins enable agentapprove",
4816
+ "",
4817
+ "The plugin reads your existing Agent Approve config from ~/.agentapprove/env.",
4818
+ "",
4819
+ "Hermes loads the plugin on the next session — no restart needed."
4820
+ ].join(`
4623
4821
  `);
4624
4822
  }
4625
4823
  return "";
@@ -4791,6 +4989,346 @@ function removePiPluginViaCli() {
4791
4989
  };
4792
4990
  }
4793
4991
  }
4992
+ var HERMES_BUNDLE_SHA256 = "2572cec0a2d8467e9ffc2000e5c52cf652265d20638244a8a70e2e3a6c9d2734";
4993
+ var HERMES_BUNDLE_FILENAME = `agentapprove-hermes-${HERMES_PLUGIN_VERSION}.tar.gz`;
4994
+ var HERMES_PLUGIN_DIR = join(homedir(), ".hermes", "plugins", "agentapprove");
4995
+ function findPython() {
4996
+ for (const candidate of ["python3.12", "python3.11", "python3.10", "python3"]) {
4997
+ try {
4998
+ const out = execSync(`${candidate} -c "import sys; print('%d.%d' % sys.version_info[:2])"`, {
4999
+ encoding: "utf-8",
5000
+ stdio: ["pipe", "pipe", "ignore"]
5001
+ }).trim();
5002
+ const [major, minor] = out.split(".").map(Number);
5003
+ if (major === 3 && minor >= 10) {
5004
+ return { command: candidate, version: out };
5005
+ }
5006
+ } catch {
5007
+ continue;
5008
+ }
5009
+ }
5010
+ return null;
5011
+ }
5012
+ function isSafeApiUrl(candidate) {
5013
+ try {
5014
+ const parsed = new URL(candidate);
5015
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
5016
+ return false;
5017
+ if (!parsed.hostname)
5018
+ return false;
5019
+ return true;
5020
+ } catch {
5021
+ return false;
5022
+ }
5023
+ }
5024
+ function isSafeApiVersion(candidate) {
5025
+ return /^[a-z0-9-]+$/i.test(candidate);
5026
+ }
5027
+ function readInstallerConfig() {
5028
+ const envPath = join(getAgentApproveDir(), "env");
5029
+ let apiUrl = API_URL;
5030
+ let apiVersion = "v001";
5031
+ let token = "";
5032
+ if (existsSync2(envPath)) {
5033
+ try {
5034
+ const content = readFileSync(envPath, "utf-8");
5035
+ for (const line of content.split(`
5036
+ `)) {
5037
+ if (line.startsWith("#"))
5038
+ continue;
5039
+ const assignment = parseEnvAssignment(line);
5040
+ if (!assignment)
5041
+ continue;
5042
+ if (assignment.key === "AGENTAPPROVE_TOKEN") {
5043
+ token = assignment.value;
5044
+ } else if (assignment.key === "AGENTAPPROVE_API") {
5045
+ const normalized = assignment.value.replace(/\/+$/, "");
5046
+ if (isSafeApiUrl(normalized)) {
5047
+ apiUrl = normalized;
5048
+ }
5049
+ } else if (assignment.key === "AGENTAPPROVE_API_VERSION") {
5050
+ const normalized = assignment.value.replace(/^\/+|\/+$/g, "");
5051
+ if (isSafeApiVersion(normalized)) {
5052
+ apiVersion = normalized;
5053
+ }
5054
+ }
5055
+ }
5056
+ } catch {}
5057
+ }
5058
+ return { apiUrl: apiUrl.replace(/\/+$/, ""), apiVersion, token };
5059
+ }
5060
+ var HERMES_BUNDLE_MAX_BYTES = 10 * 1024 * 1024;
5061
+ async function downloadHermesBundle(apiUrl, apiVersion, token) {
5062
+ if (!isSafeApiUrl(apiUrl) || !isSafeApiVersion(apiVersion)) {
5063
+ return { ok: false, error: `Refusing to download bundle: invalid API URL or version in ~/.agentapprove/env` };
5064
+ }
5065
+ let url;
5066
+ try {
5067
+ url = new URL(`${apiVersion}/hooks/${encodeURIComponent(HERMES_BUNDLE_FILENAME)}`, apiUrl.endsWith("/") ? apiUrl : apiUrl + "/");
5068
+ } catch (err) {
5069
+ return { ok: false, error: `Could not build download URL: ${err instanceof Error ? err.message : String(err)}` };
5070
+ }
5071
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
5072
+ return { ok: false, error: `Refusing to download bundle: non-http(s) URL ${url.protocol}` };
5073
+ }
5074
+ let response;
5075
+ try {
5076
+ response = await fetch(url.toString(), {
5077
+ headers: { Authorization: `Bearer ${token}` }
5078
+ });
5079
+ } catch (err) {
5080
+ return { ok: false, error: `Network error downloading ${HERMES_BUNDLE_FILENAME}: ${err instanceof Error ? err.message : String(err)}` };
5081
+ }
5082
+ if (!response.ok) {
5083
+ return { ok: false, error: `Bundle download returned ${response.status} from ${url}` };
5084
+ }
5085
+ const declared = parseInt(response.headers.get("content-length") || "", 10);
5086
+ if (Number.isFinite(declared) && declared > HERMES_BUNDLE_MAX_BYTES) {
5087
+ return { ok: false, error: `Bundle exceeds ${HERMES_BUNDLE_MAX_BYTES} byte limit (server reported ${declared}). Refusing to download.` };
5088
+ }
5089
+ try {
5090
+ const buffer = await response.arrayBuffer();
5091
+ if (buffer.byteLength > HERMES_BUNDLE_MAX_BYTES) {
5092
+ return { ok: false, error: `Bundle body exceeded ${HERMES_BUNDLE_MAX_BYTES} byte limit (got ${buffer.byteLength}).` };
5093
+ }
5094
+ return { ok: true, bytes: new Uint8Array(buffer) };
5095
+ } catch (err) {
5096
+ return { ok: false, error: `Failed to read bundle body: ${err instanceof Error ? err.message : String(err)}` };
5097
+ }
5098
+ }
5099
+ function verifyBundleHash(bytes) {
5100
+ return createHash("sha256").update(bytes).digest("hex");
5101
+ }
5102
+ function validateExtractedTree(root) {
5103
+ const realRoot = realpathSync2(root);
5104
+ const stack = [root];
5105
+ while (stack.length > 0) {
5106
+ const dir = stack.pop();
5107
+ let entries;
5108
+ try {
5109
+ entries = readdirSync2(dir, { withFileTypes: true });
5110
+ } catch (err) {
5111
+ return { ok: false, error: `Cannot read staged dir ${dir}: ${err instanceof Error ? err.message : String(err)}` };
5112
+ }
5113
+ for (const entry of entries) {
5114
+ const fullPath = join(dir, String(entry.name));
5115
+ let resolved;
5116
+ try {
5117
+ resolved = realpathSync2(fullPath);
5118
+ } catch (err) {
5119
+ return { ok: false, error: `Cannot resolve ${fullPath}: ${err instanceof Error ? err.message : String(err)}` };
5120
+ }
5121
+ if (resolved !== realRoot && !resolved.startsWith(realRoot + sep2)) {
5122
+ return { ok: false, error: `Bundle entry escapes staging dir: ${String(entry.name)} → ${resolved}` };
5123
+ }
5124
+ if (!entry.isFile() && !entry.isDirectory()) {
5125
+ return { ok: false, error: `Bundle contains non-regular entry: ${String(entry.name)} (${entry.isSymbolicLink() ? "symlink" : "other"})` };
5126
+ }
5127
+ if (entry.isDirectory()) {
5128
+ stack.push(fullPath);
5129
+ }
5130
+ }
5131
+ }
5132
+ return { ok: true };
5133
+ }
5134
+ function hermesDepsAlreadyInstalled(pythonCommand) {
5135
+ try {
5136
+ execSync(`${pythonCommand} -c "import cryptography, httpx"`, {
5137
+ stdio: "pipe",
5138
+ timeout: 5000
5139
+ });
5140
+ return true;
5141
+ } catch {
5142
+ return false;
5143
+ }
5144
+ }
5145
+ async function installHermesPluginViaCli() {
5146
+ try {
5147
+ execSync("hermes --version", { stdio: "pipe" });
5148
+ } catch {
5149
+ return {
5150
+ success: false,
5151
+ label: "Agent Approve plugin",
5152
+ error: "Hermes CLI not found on PATH. Install Hermes from https://hermes-agent.nousresearch.com first, then re-run npx agentapprove."
5153
+ };
5154
+ }
5155
+ const python = findPython();
5156
+ if (!python) {
5157
+ return {
5158
+ success: false,
5159
+ label: "Agent Approve plugin",
5160
+ error: "Python 3.10+ not found on PATH. Install Python 3.10 or newer (e.g. `brew install python@3.12`) and re-run npx agentapprove."
5161
+ };
5162
+ }
5163
+ const cfg = readInstallerConfig();
5164
+ if (!cfg.token) {
5165
+ return {
5166
+ success: false,
5167
+ label: "Agent Approve plugin",
5168
+ error: "Missing API token. Pair your phone first by running `npx agentapprove` and completing the QR scan."
5169
+ };
5170
+ }
5171
+ const download = await downloadHermesBundle(cfg.apiUrl, cfg.apiVersion, cfg.token);
5172
+ if (!download.ok) {
5173
+ return { success: false, label: "Agent Approve plugin", error: download.error };
5174
+ }
5175
+ const actualHash = verifyBundleHash(download.bytes);
5176
+ if (actualHash !== HERMES_BUNDLE_SHA256) {
5177
+ return {
5178
+ success: false,
5179
+ label: "Agent Approve plugin",
5180
+ error: `Bundle integrity check failed: expected ${HERMES_BUNDLE_SHA256.slice(0, 16)}…, got ${actualHash.slice(0, 16)}…. Refusing to install.`
5181
+ };
5182
+ }
5183
+ let tmpDir;
5184
+ try {
5185
+ tmpDir = mkdtempSync(join(tmpdir(), "aa-hermes-"));
5186
+ } catch (err) {
5187
+ return {
5188
+ success: false,
5189
+ label: "Agent Approve plugin",
5190
+ error: `Could not create temp directory for bundle staging: ${err instanceof Error ? err.message : String(err)}`
5191
+ };
5192
+ }
5193
+ const tarballPath = join(tmpDir, HERMES_BUNDLE_FILENAME);
5194
+ let backupPath = null;
5195
+ try {
5196
+ writeFileSync(tarballPath, download.bytes);
5197
+ try {
5198
+ execSync(`tar -xzf "${tarballPath}" -C "${tmpDir}" --no-same-owner --no-same-permissions`, { stdio: "pipe" });
5199
+ } catch (err) {
5200
+ return {
5201
+ success: false,
5202
+ label: "Agent Approve plugin",
5203
+ error: `tar extract failed: ${err instanceof Error ? err.message : String(err)}`
5204
+ };
5205
+ }
5206
+ const stagedDir = join(tmpDir, "agentapprove");
5207
+ if (!existsSync2(stagedDir)) {
5208
+ return {
5209
+ success: false,
5210
+ label: "Agent Approve plugin",
5211
+ error: "Bundle did not contain expected `agentapprove/` directory."
5212
+ };
5213
+ }
5214
+ const validation = validateExtractedTree(stagedDir);
5215
+ if (!validation.ok) {
5216
+ return {
5217
+ success: false,
5218
+ label: "Agent Approve plugin",
5219
+ error: `Bundle integrity violation: ${validation.error}`
5220
+ };
5221
+ }
5222
+ mkdirSync(dirname2(HERMES_PLUGIN_DIR), { recursive: true });
5223
+ if (existsSync2(HERMES_PLUGIN_DIR)) {
5224
+ backupPath = `${HERMES_PLUGIN_DIR}.bak-${Date.now()}`;
5225
+ renameSync(HERMES_PLUGIN_DIR, backupPath);
5226
+ }
5227
+ try {
5228
+ renameSync(stagedDir, HERMES_PLUGIN_DIR);
5229
+ } catch (err) {
5230
+ if (backupPath) {
5231
+ try {
5232
+ renameSync(backupPath, HERMES_PLUGIN_DIR);
5233
+ backupPath = null;
5234
+ } catch {}
5235
+ }
5236
+ const restoreNote = backupPath ? ` Your previous install was preserved at ${backupPath} — recover with: mv "${backupPath}" "${HERMES_PLUGIN_DIR}"` : "";
5237
+ return {
5238
+ success: false,
5239
+ label: "Agent Approve plugin",
5240
+ error: `Could not install bundle to ${HERMES_PLUGIN_DIR}: ${err instanceof Error ? err.message : String(err)}.${restoreNote}`
5241
+ };
5242
+ }
5243
+ writeFileSync(join(HERMES_PLUGIN_DIR, ".bundle-hash"), HERMES_BUNDLE_SHA256, { mode: 420 });
5244
+ if (backupPath) {
5245
+ rmSync2(backupPath, { recursive: true, force: true });
5246
+ backupPath = null;
5247
+ }
5248
+ } catch (err) {
5249
+ if (backupPath) {
5250
+ try {
5251
+ rmSync2(HERMES_PLUGIN_DIR, { recursive: true, force: true });
5252
+ renameSync(backupPath, HERMES_PLUGIN_DIR);
5253
+ backupPath = null;
5254
+ } catch {}
5255
+ }
5256
+ const restoreNote = backupPath ? ` Your previous install was preserved at ${backupPath} — recover with: mv "${backupPath}" "${HERMES_PLUGIN_DIR}"` : "";
5257
+ return {
5258
+ success: false,
5259
+ label: "Agent Approve plugin",
5260
+ error: `Bundle install failed: ${err instanceof Error ? err.message : String(err)}.${restoreNote}`
5261
+ };
5262
+ } finally {
5263
+ try {
5264
+ rmSync2(tmpDir, { recursive: true, force: true });
5265
+ } catch {}
5266
+ if (backupPath && existsSync2(HERMES_PLUGIN_DIR)) {
5267
+ try {
5268
+ rmSync2(backupPath, { recursive: true, force: true });
5269
+ } catch {}
5270
+ }
5271
+ }
5272
+ if (!hermesDepsAlreadyInstalled(python.command)) {
5273
+ const depsArg = HERMES_PIP_DEPS.map((d2) => `"${d2}"`).join(" ");
5274
+ try {
5275
+ execSync(`${python.command} -m pip install --user --quiet ${depsArg}`, { stdio: "pipe" });
5276
+ } catch (err) {
5277
+ return {
5278
+ success: false,
5279
+ label: "Agent Approve plugin",
5280
+ error: `Installed bundle but could not install Python runtime deps: ${err instanceof Error ? err.message : String(err)}. ` + `Try manually: ${python.command} -m pip install --user ${HERMES_PIP_DEPS.join(" ")}`
5281
+ };
5282
+ }
5283
+ }
5284
+ try {
5285
+ execSync("hermes plugins enable agentapprove", { stdio: "pipe" });
5286
+ } catch (err) {
5287
+ return {
5288
+ success: false,
5289
+ label: "Agent Approve plugin",
5290
+ error: `Installed bundle but could not enable plugin in Hermes: ${err instanceof Error ? err.message : String(err)}. ` + `Try manually: hermes plugins enable agentapprove`
5291
+ };
5292
+ }
5293
+ return { success: true, label: `Agent Approve plugin ${HERMES_PLUGIN_VERSION}` };
5294
+ }
5295
+ function isHermesDisableNoOp(err) {
5296
+ if (!err || typeof err !== "object")
5297
+ return false;
5298
+ const e2 = err;
5299
+ const stderr = Buffer.isBuffer(e2.stderr) ? e2.stderr.toString() : String(e2.stderr || "");
5300
+ const stdout = Buffer.isBuffer(e2.stdout) ? e2.stdout.toString() : String(e2.stdout || "");
5301
+ const haystack = `${e2.message || ""}
5302
+ ${stderr}
5303
+ ${stdout}`.toLowerCase();
5304
+ return haystack.includes("not enabled") || haystack.includes("not installed") || haystack.includes("is not enabled") || haystack.includes("plugin agentapprove not found") || haystack.includes("plugin 'agentapprove' not found");
5305
+ }
5306
+ function disableHermesPluginViaCli() {
5307
+ try {
5308
+ execSync("hermes plugins disable agentapprove", { stdio: "pipe" });
5309
+ return { success: true };
5310
+ } catch (err) {
5311
+ if (isHermesDisableNoOp(err)) {
5312
+ return { success: true, alreadyDisabled: true };
5313
+ }
5314
+ return {
5315
+ success: false,
5316
+ error: err instanceof Error ? err.message : String(err)
5317
+ };
5318
+ }
5319
+ }
5320
+ function removeHermesPlugin() {
5321
+ const disabled = disableHermesPluginViaCli();
5322
+ try {
5323
+ rmSync2(HERMES_PLUGIN_DIR, { recursive: true, force: true });
5324
+ return disabled.success ? { success: true } : { success: false, error: `disable failed: ${disabled.error || "unknown"}` };
5325
+ } catch (err) {
5326
+ return {
5327
+ success: false,
5328
+ error: err instanceof Error ? err.message : String(err)
5329
+ };
5330
+ }
5331
+ }
4794
5332
  var SYSTEM_DEPS = [
4795
5333
  {
4796
5334
  name: "curl",
@@ -5117,7 +5655,7 @@ async function installCommand() {
5117
5655
  process.exit(1);
5118
5656
  }
5119
5657
  me(`Approve AI agent actions from your iPhone or Apple Watch.
5120
- Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw, and more.`, "About");
5658
+ Installs hooks and extensions for Cursor, Windsurf, Claude, Gemini, Pi, OpenCode, OpenClaw, and more.`, "About");
5121
5659
  const installedAgents = detectInstalledAgents();
5122
5660
  const agentOptions = Object.entries(AGENTS).map(([id, agent]) => ({
5123
5661
  value: id,
@@ -5531,6 +6069,9 @@ AGENTAPPROVE_E2E_MODE=${installMode}
5531
6069
  if (agentId === "pi") {
5532
6070
  filesToModify.push("Pi package registry (via `pi install`)");
5533
6071
  }
6072
+ if (agentId === "hermes") {
6073
+ filesToModify.push(`Hermes plugin directory (download + verify ${HERMES_BUNDLE_FILENAME} → extract to ~/.hermes/plugins/agentapprove/ → \`hermes plugins enable agentapprove\`)`);
6074
+ }
5534
6075
  if (agentId === "vscode-agent") {
5535
6076
  const vsCodeVariants = findInstalledVSCodeVariants();
5536
6077
  for (const { path: settingsPath, variant } of vsCodeVariants) {
@@ -5552,7 +6093,7 @@ Backups will be created with timestamp`, "Files to be modified");
5552
6093
  for (const agentId of selectedAgents) {
5553
6094
  const agent = AGENTS[agentId];
5554
6095
  const spinner = _2();
5555
- const spinnerMsg = agentId === "pi" ? `Installing ${agent.name} extension` : agentId === "openclaw" ? `Installing ${agent.name} plugin` : `Configuring ${agent.name}`;
6096
+ const spinnerMsg = agentId === "pi" ? `Installing ${agent.name} extension` : agentId === "openclaw" || agentId === "hermes" ? `Installing ${agent.name} plugin` : `Configuring ${agent.name}`;
5556
6097
  spinner.start(spinnerMsg);
5557
6098
  const result = await installHooksForAgent(agentId, hooksDir, installMode);
5558
6099
  if (result.success) {
@@ -5593,6 +6134,17 @@ Backups will be created with timestamp`, "Files to be modified");
5593
6134
  v2.warn(`Could not install ${PI_PLUGIN_SPEC} via Pi.
5594
6135
  ` + ` Error: ${result.error || "unknown"}
5595
6136
  ` + ` Install manually: pi install ${PI_PLUGIN_SPEC}
6137
+ ` + ` Then re-run: npx agentapprove`);
6138
+ } else if (agentId === "hermes") {
6139
+ v2.warn(`Could not install Agent Approve plugin for Hermes.
6140
+ ` + ` Error: ${result.error || "unknown"}
6141
+ ` + ` Install manually:
6142
+ ` + ` 1. Download ${HERMES_BUNDLE_FILENAME} from your Agent Approve API
6143
+ ` + ` 2. Verify SHA-256 matches ${HERMES_BUNDLE_SHA256.slice(0, 16)}…
6144
+ ` + ` 3. Extract into ~/.hermes/plugins/agentapprove/
6145
+ ` + ` 4. Write the SHA-256 to ~/.hermes/plugins/agentapprove/.bundle-hash
6146
+ ` + ` 5. pip install --user cryptography httpx
6147
+ ` + ` 6. hermes plugins enable agentapprove
5596
6148
  ` + ` Then re-run: npx agentapprove`);
5597
6149
  }
5598
6150
  }
@@ -5668,6 +6220,22 @@ async function statusCommand() {
5668
6220
  } catch {}
5669
6221
  continue;
5670
6222
  }
6223
+ if (agentId === "hermes") {
6224
+ try {
6225
+ const listOutput = execSync("hermes plugins list", {
6226
+ encoding: "utf-8",
6227
+ stdio: ["pipe", "pipe", "ignore"],
6228
+ timeout: PI_STATUS_TIMEOUT_MS
6229
+ });
6230
+ const enabled = /^.*agentapprove.*enabled/im.test(listOutput);
6231
+ if (enabled) {
6232
+ console.log(` ${source_default.green("✓")} ${agent.name}: Agent Approve plugin`);
6233
+ } else if (listOutput.includes("agentapprove")) {
6234
+ console.log(` ${source_default.yellow("⚠")} ${agent.name}: Agent Approve plugin installed but disabled (run: hermes plugins enable agentapprove)`);
6235
+ }
6236
+ } catch {}
6237
+ continue;
6238
+ }
5671
6239
  if (existsSync2(agent.configPath)) {
5672
6240
  const config = readJsonConfig(agent.configPath);
5673
6241
  if (agentId === "opencode") {
@@ -5684,7 +6252,7 @@ async function statusCommand() {
5684
6252
  }
5685
6253
  continue;
5686
6254
  }
5687
- const hooksConfig = config[agent.hooksKey];
6255
+ const hooksConfig = agent.hooksKey === "" ? config : config[agent.hooksKey];
5688
6256
  if (hooksConfig) {
5689
6257
  const installedHooks = agent.hooks.filter((h2) => {
5690
6258
  const hookEntry = hooksConfig[h2.name];
@@ -5764,10 +6332,19 @@ async function performUninstall(mode) {
5764
6332
  }
5765
6333
  continue;
5766
6334
  }
6335
+ if (agentId === "hermes") {
6336
+ const result = removeHermesPlugin();
6337
+ if (result.success) {
6338
+ console.log(` ${source_default.green("✓")} Hermes plugin disabled and removed`);
6339
+ } else {
6340
+ console.log(` ${source_default.yellow("⚠")} Hermes plugin removal partial (${result.error || "unknown error"})`);
6341
+ }
6342
+ continue;
6343
+ }
5767
6344
  if (!existsSync2(agent.configPath))
5768
6345
  continue;
5769
6346
  const config = readJsonConfig(agent.configPath);
5770
- const hooksConfig = config[agent.hooksKey] ?? {};
6347
+ const hooksConfig = agent.hooksKey === "" ? config : config[agent.hooksKey] ?? {};
5771
6348
  let modified = false;
5772
6349
  if (agentId === "opencode") {
5773
6350
  const pluginArray = config.plugin;
@@ -6317,7 +6894,9 @@ async function updateHookScriptsWithToken(token, apiUrl) {
6317
6894
  "cursor-thought.sh",
6318
6895
  "cursor-response.sh",
6319
6896
  "gemini-approval.sh",
6320
- "gemini-complete.sh"
6897
+ "gemini-complete.sh",
6898
+ "windsurf-hook.sh",
6899
+ "openhands-hook.sh"
6321
6900
  ];
6322
6901
  for (const file of hookFiles) {
6323
6902
  const filePath = join(hooksDir, file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentapprove",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Approve AI agent actions from your iPhone or Apple Watch",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "claude",
22
22
  "claude-code",
23
23
  "cursor",
24
+ "windsurf",
24
25
  "gemini",
25
26
  "openclaw",
26
27
  "opencode",