@pushary/agent-hooks 0.5.0 → 0.6.0

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.
@@ -10,6 +10,7 @@ import { join } from "path";
10
10
  import { homedir } from "os";
11
11
  import { execSync } from "child_process";
12
12
  import { confirm } from "@inquirer/prompts";
13
+ import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
13
14
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
14
15
  var bold = (s) => `\x1B[1m${s}\x1B[0m`;
15
16
  var green = (s) => `\x1B[32m${s}\x1B[0m`;
@@ -21,7 +22,7 @@ var CLAUDE_SETTINGS_LOCAL = join(homedir(), ".claude", "settings.local.json");
21
22
  var CLAUDE_JSON = join(homedir(), ".claude.json");
22
23
  var SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
23
24
  var CURSOR_MCP = join(".cursor", "mcp.json");
24
- var SHELL_FILES = [".zshrc", ".bashrc"].map((f) => join(homedir(), f));
25
+ var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
25
26
  var readJson = (path) => {
26
27
  try {
27
28
  return JSON.parse(readFileSync(path, "utf-8"));
@@ -91,26 +92,19 @@ var main = async () => {
91
92
  }
92
93
  const codexConfig = join(homedir(), ".codex", "config.toml");
93
94
  try {
94
- let config = readFileSync(codexConfig, "utf-8");
95
+ const config = readFileSync(codexConfig, "utf-8");
95
96
  const hadPushary = config.includes("pushary");
96
97
  if (hadPushary) {
97
- const lines = config.split("\n");
98
- const cleaned = [];
99
- let skipping = false;
100
- for (const line of lines) {
101
- if (line.trim() === "[mcp_servers.pushary]") {
102
- skipping = true;
103
- continue;
104
- }
105
- if (skipping && (line.startsWith("[") || line.trim() === "")) {
106
- skipping = false;
107
- }
108
- if (skipping) continue;
109
- if (line.includes("pushary-codex")) continue;
110
- cleaned.push(line);
98
+ const parsed = parseTOML(config);
99
+ const mcpServers = parsed.mcp_servers ?? {};
100
+ delete mcpServers.pushary;
101
+ if (Object.keys(mcpServers).length === 0) delete parsed.mcp_servers;
102
+ else parsed.mcp_servers = mcpServers;
103
+ if (Array.isArray(parsed.notify)) {
104
+ parsed.notify = parsed.notify.filter((n) => typeof n !== "string" || !n.includes("pushary-codex"));
105
+ if (parsed.notify.length === 0) delete parsed.notify;
111
106
  }
112
- config = cleaned.join("\n").replace(/\n{3,}/g, "\n\n");
113
- writeFileSync(codexConfig, config, "utf-8");
107
+ writeFileSync(codexConfig, stringifyTOML(parsed), "utf-8");
114
108
  console.log(` ${check} Codex config ${dim("(cleaned)")}`);
115
109
  } else {
116
110
  console.log(` ${skip} Codex config ${dim("(no pushary entries)")}`);
@@ -130,7 +124,7 @@ var main = async () => {
130
124
  }
131
125
  }
132
126
  try {
133
- execSync("npm uninstall -g @pushary/agent-hooks", { stdio: "ignore" });
127
+ execSync("npm uninstall -g @pushary/agent-hooks", { stdio: "ignore", timeout: 3e4 });
134
128
  console.log(` ${check} Global package ${dim("(uninstalled)")}`);
135
129
  } catch {
136
130
  console.log(` ${skip} Global package ${dim("(not installed)")}`);
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  reportEvent
4
- } from "../chunk-P4JH2Q7Z.js";
4
+ } from "../chunk-KYARP7KP.js";
5
5
  import {
6
6
  askUser,
7
7
  waitForAnswer
8
- } from "../chunk-KTP2EPVB.js";
8
+ } from "../chunk-4Z4MB37G.js";
9
9
  import {
10
10
  getApiKey
11
- } from "../chunk-VIST7ACL.js";
11
+ } from "../chunk-O6A5RHWY.js";
12
12
 
13
13
  // bin/pushary-codex.ts
14
14
  import { hostname } from "os";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  callMcpTool,
4
4
  sendMcpRequest
5
- } from "../chunk-VIST7ACL.js";
5
+ } from "../chunk-O6A5RHWY.js";
6
6
 
7
7
  // bin/pushary-doctor.ts
8
8
  import { existsSync, readFileSync } from "fs";
@@ -68,11 +68,28 @@ var main = async () => {
68
68
  } else {
69
69
  check(false, "Claude Code: settings.json", "not found");
70
70
  }
71
+ const codexConfigPath = join(homedir(), ".codex", "config.toml");
72
+ if (existsSync(codexConfigPath)) {
73
+ const codexConfig = readFileSync(codexConfigPath, "utf-8");
74
+ const hasPusharyMcp = codexConfig.includes("[mcp_servers.pushary]");
75
+ check(hasPusharyMcp, "Codex: MCP server configured");
76
+ if (hasPusharyMcp) {
77
+ const hasAutoApprove = codexConfig.includes('default_tools_approval_mode = "approve"');
78
+ check(hasAutoApprove, "Codex: tools auto-allowed", hasAutoApprove ? 'default_tools_approval_mode = "approve"' : "missing \u2014 MCP calls will prompt for approval");
79
+ const hasPerToolOverrides = /\[mcp_servers\.pushary\.tools\./.test(codexConfig);
80
+ if (hasPerToolOverrides) {
81
+ console.log(` ${warn} Codex: per-tool approval overrides detected ${dim("(redundant with default_tools_approval_mode)")}`);
82
+ }
83
+ }
84
+ check(codexConfig.includes("pushary-codex"), "Codex: notify handler configured");
85
+ const codexSkillPath = join(homedir(), ".codex", "skills", "pushary", "SKILL.md");
86
+ check(existsSync(codexSkillPath), "Codex: skill installed");
87
+ }
71
88
  check(existsSync(SKILL_PATH), "Skill installed", existsSync(SKILL_PATH) ? SKILL_PATH : "not found");
72
89
  let globalVersion = "";
73
90
  try {
74
91
  const { execSync } = await import("child_process");
75
- globalVersion = execSync("npm list -g @pushary/agent-hooks --depth=0 2>/dev/null", { encoding: "utf-8" }).match(/@pushary\/agent-hooks@([\d.]+)/)?.[1] ?? "";
92
+ globalVersion = execSync("npm list -g @pushary/agent-hooks --depth=0 2>/dev/null", { encoding: "utf-8", timeout: 1e4 }).match(/@pushary\/agent-hooks@([\d.]+)/)?.[1] ?? "";
76
93
  } catch {
77
94
  }
78
95
  check(!!globalVersion, "Global package installed", globalVersion || "not found");
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePreToolUse
4
- } from "../chunk-DF3BM6BF.js";
5
- import "../chunk-KTP2EPVB.js";
6
- import "../chunk-VIST7ACL.js";
4
+ } from "../chunk-WNXGIEX7.js";
5
+ import "../chunk-4Z4MB37G.js";
6
+ import "../chunk-O6A5RHWY.js";
7
7
 
8
8
  // bin/pushary-hook.ts
9
9
  var main = async () => {
@@ -25,14 +25,19 @@ var main = async () => {
25
25
  }
26
26
  try {
27
27
  const output = await handlePreToolUse(input);
28
- if (output) {
29
- process.stdout.write(JSON.stringify(output));
30
- }
28
+ process.stdout.write(JSON.stringify(output));
31
29
  } catch (err) {
32
30
  process.stderr.write(
33
31
  `[pushary-hook] Error: ${err instanceof Error ? err.message : String(err)}
34
32
  `
35
33
  );
34
+ process.stdout.write(JSON.stringify({
35
+ hookSpecificOutput: {
36
+ hookEventName: "PreToolUse",
37
+ permissionDecision: "ask",
38
+ permissionDecisionReason: "Pushary hook error, falling back to terminal"
39
+ }
40
+ }));
36
41
  }
37
42
  };
38
43
  main();
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePostToolUse
4
- } from "../chunk-P4JH2Q7Z.js";
5
- import "../chunk-KTP2EPVB.js";
6
- import "../chunk-VIST7ACL.js";
4
+ } from "../chunk-KYARP7KP.js";
5
+ import "../chunk-4Z4MB37G.js";
6
+ import "../chunk-O6A5RHWY.js";
7
7
 
8
8
  // bin/pushary-post-hook.ts
9
9
  var main = async () => {
@@ -12,13 +12,14 @@ import { homedir } from "os";
12
12
  import { execSync } from "child_process";
13
13
  import { checkbox, input, confirm } from "@inquirer/prompts";
14
14
  import { fileURLToPath } from "url";
15
+ import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
15
16
  var CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
16
17
  var CLAUDE_JSON = join(homedir(), ".claude.json");
17
18
  var CURSOR_MCP = join(".cursor", "mcp.json");
18
19
  var CURSOR_RULES_DIR = join(".cursor", "rules");
19
20
  var CLAUDE_SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
20
21
  var CODEX_SKILL_DIR = join(homedir(), ".codex", "skills", "pushary");
21
- var SHELL_FILES = [".zshrc", ".bashrc"].map((f) => join(homedir(), f));
22
+ var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
22
23
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
23
24
  var bold = (s) => `\x1B[1m${s}\x1B[0m`;
24
25
  var green = (s) => `\x1B[32m${s}\x1B[0m`;
@@ -37,7 +38,16 @@ var writeJson = (path, data) => {
37
38
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
38
39
  writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
39
40
  };
40
- var formatError = (err) => err instanceof Error ? err.message : String(err);
41
+ var formatError = (err) => {
42
+ if (err instanceof Error) {
43
+ const errWithSignal = err;
44
+ if (errWithSignal.killed && errWithSignal.signal === "SIGTERM") {
45
+ return "timed out \u2014 check your network connection";
46
+ }
47
+ return err.message;
48
+ }
49
+ return String(err);
50
+ };
41
51
  var spinner = async (label, fn, options = {}) => {
42
52
  const frames = [" ", ". ", ".. ", "..."];
43
53
  let i = 0;
@@ -82,7 +92,7 @@ var checkForUpdates = async (current) => {
82
92
  };
83
93
  var installGlobally = async () => {
84
94
  await spinner("Installing pushary-hook globally", async () => {
85
- execSync("npm install -g @pushary/agent-hooks@latest", { stdio: "ignore" });
95
+ execSync("npm install -g @pushary/agent-hooks@latest", { stdio: "ignore", timeout: 12e4 });
86
96
  });
87
97
  };
88
98
  var _cachedSkillContent = null;
@@ -146,31 +156,88 @@ var setupClaudeCode = async (apiKey) => {
146
156
  console.log(` ${dim("\u2022")} Hooks: route permission approvals through push notifications`);
147
157
  console.log(` ${dim("\u2022")} Auto-allowed tools: no permission prompts for Pushary MCP calls`);
148
158
  };
159
+ var findPython310Plus = () => {
160
+ const candidates = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python"];
161
+ for (const py of candidates) {
162
+ try {
163
+ const version = execSync(`${py} --version 2>&1`, { encoding: "utf-8", stdio: "pipe", timeout: 5e3 }).trim();
164
+ const match = version.match(/Python (\d+)\.(\d+)/);
165
+ if (match && (Number(match[1]) > 3 || Number(match[1]) === 3 && Number(match[2]) >= 10)) {
166
+ return py;
167
+ }
168
+ } catch {
169
+ }
170
+ }
171
+ return null;
172
+ };
173
+ var installPythonPlugin = (pythonBin) => {
174
+ execSync(`${pythonBin} -m pip install --upgrade hermes-plugin-pushary`, { stdio: "pipe", timeout: 12e4 });
175
+ };
149
176
  var setupHermes = async (_apiKey) => {
150
177
  console.log(`
151
178
  ${bold("Setting up Hermes Agent")}
152
179
  `);
153
- let pipInstalled = false;
180
+ const whichCmd = process.platform === "win32" ? "where" : "which";
181
+ const hasHermes = (() => {
182
+ try {
183
+ execSync(`${whichCmd} hermes`, { stdio: "ignore", timeout: 5e3 });
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ })();
189
+ if (!hasHermes) {
190
+ console.log(` ${yellow("!")} Hermes CLI not found. Skipping.`);
191
+ console.log(` ${dim("Install Hermes and re-run setup to configure.")}`);
192
+ return;
193
+ }
154
194
  await spinner("Installing hermes-plugin-pushary", async () => {
195
+ try {
196
+ execSync("uv pip install hermes-plugin-pushary", { stdio: "pipe", timeout: 12e4 });
197
+ return;
198
+ } catch {
199
+ }
200
+ let python = findPython310Plus();
201
+ if (!python) {
202
+ if (process.platform === "darwin") {
203
+ try {
204
+ execSync("which brew", { stdio: "ignore", timeout: 5e3 });
205
+ execSync("brew install python@3.12", { stdio: "pipe", timeout: 3e5 });
206
+ python = findPython310Plus();
207
+ } catch {
208
+ }
209
+ } else if (process.platform === "linux") {
210
+ for (const [check2, install] of [
211
+ ["which apt-get", "sudo apt-get update -qq && sudo apt-get install -y -qq python3 python3-pip"],
212
+ ["which dnf", "sudo dnf install -y -q python3 python3-pip"],
213
+ ["which yum", "sudo yum install -y -q python3 python3-pip"],
214
+ ["which pacman", "sudo pacman -S --noconfirm python python-pip"]
215
+ ]) {
216
+ try {
217
+ execSync(check2, { stdio: "ignore", timeout: 5e3 });
218
+ execSync(install, { stdio: "pipe", timeout: 3e5 });
219
+ python = findPython310Plus();
220
+ if (python) break;
221
+ } catch {
222
+ }
223
+ }
224
+ }
225
+ }
226
+ if (python) {
227
+ installPythonPlugin(python);
228
+ return;
229
+ }
155
230
  for (const pip of ["pip3", "pip"]) {
156
231
  try {
157
- execSync(`${pip} install hermes-plugin-pushary`, { stdio: "pipe" });
158
- pipInstalled = true;
232
+ execSync(`${pip} install hermes-plugin-pushary`, { stdio: "pipe", timeout: 12e4 });
159
233
  return;
160
- } catch (err) {
161
- const msg = err instanceof Error ? err.stderr?.toString() ?? "" : "";
162
- if (msg.includes("No matching distribution")) {
163
- throw new Error("requires Python 3.10+ (your pip uses an older version)");
164
- }
234
+ } catch {
165
235
  }
166
236
  }
167
- throw new Error("pip not found");
237
+ throw new Error("Python 3.10+ not found and could not be installed");
168
238
  });
169
- if (!pipInstalled) {
170
- console.log(` ${dim(" Install Python 3.10+ and re-run setup to fix.")}`);
171
- }
172
239
  await spinner("Enabling plugin", async () => {
173
- execSync("hermes plugins enable pushary", { stdio: "ignore" });
240
+ execSync("hermes plugins enable pushary", { stdio: "ignore", timeout: 1e4 });
174
241
  });
175
242
  console.log();
176
243
  console.log(` ${dim("What this configured:")}`);
@@ -181,9 +248,10 @@ var setupCodex = async (_apiKey) => {
181
248
  console.log(`
182
249
  ${bold("Setting up Codex")}
183
250
  `);
251
+ const whichCmd = process.platform === "win32" ? "where" : "which";
184
252
  const hasCodex = (() => {
185
253
  try {
186
- execSync("which codex", { stdio: "ignore" });
254
+ execSync(`${whichCmd} codex`, { stdio: "ignore", timeout: 5e3 });
187
255
  return true;
188
256
  } catch {
189
257
  return false;
@@ -195,36 +263,53 @@ var setupCodex = async (_apiKey) => {
195
263
  return;
196
264
  }
197
265
  await installGlobally();
266
+ const codexConfig = join(homedir(), ".codex", "config.toml");
198
267
  await spinner("Adding Pushary MCP server to Codex", async () => {
199
268
  try {
200
269
  execSync(
201
270
  "codex mcp add pushary --url https://pushary.com/api/mcp/mcp --bearer-token-env-var PUSHARY_API_KEY",
202
- { stdio: "ignore" }
271
+ { stdio: "ignore", timeout: 15e3 }
203
272
  );
204
273
  } catch {
205
274
  }
206
275
  });
207
- const codexConfig = join(homedir(), ".codex", "config.toml");
276
+ await spinner("Auto-allowing all Pushary tools", async () => {
277
+ let raw = "";
278
+ try {
279
+ raw = readFileSync(codexConfig, "utf-8");
280
+ } catch {
281
+ }
282
+ const config = raw ? parseTOML(raw) : {};
283
+ const mcpServers = config.mcp_servers ?? {};
284
+ const pushary = mcpServers.pushary ?? {};
285
+ pushary.default_tools_approval_mode = "approve";
286
+ delete pushary.tools;
287
+ mcpServers.pushary = pushary;
288
+ config.mcp_servers = mcpServers;
289
+ writeFileSync(codexConfig, stringifyTOML(config), "utf-8");
290
+ });
208
291
  await spinner("Adding notify handler for Codex events", async () => {
209
- const globalPrefix = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
292
+ const globalPrefix = execSync("npm prefix -g", { encoding: "utf-8", timeout: 5e3 }).trim();
210
293
  const pusharyCodexPath = join(globalPrefix, "bin", "pushary-codex");
211
294
  if (!existsSync(pusharyCodexPath)) throw new Error("pushary-codex not found at " + pusharyCodexPath);
212
- let config = "";
295
+ let raw = "";
213
296
  try {
214
- config = readFileSync(codexConfig, "utf-8");
297
+ raw = readFileSync(codexConfig, "utf-8");
215
298
  } catch {
216
299
  }
217
- if (!config.includes("pushary-codex")) {
218
- const notifyLine = `
219
- notify = ["${pusharyCodexPath}"]
220
- `;
221
- appendFileSync(codexConfig, notifyLine, "utf-8");
300
+ const config = raw ? parseTOML(raw) : {};
301
+ const notify = Array.isArray(config.notify) ? config.notify : [];
302
+ if (!notify.some((n) => typeof n === "string" && n.includes("pushary-codex"))) {
303
+ notify.push(pusharyCodexPath);
304
+ config.notify = notify;
305
+ writeFileSync(codexConfig, stringifyTOML(config), "utf-8");
222
306
  }
223
307
  });
224
308
  await installSkillToDir(CODEX_SKILL_DIR, "Installing Pushary skill");
225
309
  console.log();
226
310
  console.log(` ${dim("What this configured:")}`);
227
311
  console.log(` ${dim("\u2022")} MCP server: Codex can send notifications and ask questions`);
312
+ console.log(` ${dim("\u2022")} Auto-allowed tools: no permission prompts for Pushary MCP calls`);
228
313
  console.log(` ${dim("\u2022")} Notify handler: captures turn completions and approval requests`);
229
314
  };
230
315
  var setupCursor = async (apiKey) => {
@@ -324,7 +409,7 @@ var main = async () => {
324
409
  message: "Which agents do you use? " + dim("(space = toggle, enter = confirm)"),
325
410
  choices: [
326
411
  { name: `Claude Code ${dim("MCP + hooks + auto-allowed tools")}`, value: "claude_code" },
327
- { name: `Codex ${dim("MCP server via codex mcp add")}`, value: "codex" },
412
+ { name: `Codex ${dim("MCP + notify handler + auto-allowed tools")}`, value: "codex" },
328
413
  { name: `Hermes ${dim("native plugin + auto-error notifications")}`, value: "hermes" },
329
414
  { name: `Cursor ${dim("MCP server")}`, value: "cursor" }
330
415
  ]
@@ -334,8 +419,19 @@ var main = async () => {
334
419
  console.log(`
335
420
  ${dim("No agents selected. API key saved.")}`);
336
421
  } else {
422
+ const failed = [];
337
423
  for (const agent of agents) {
338
- await AGENT_SETUP[agent](trimmedKey);
424
+ try {
425
+ await AGENT_SETUP[agent](trimmedKey);
426
+ } catch (err) {
427
+ failed.push(agent.replace("_", " "));
428
+ console.log(` ${yellow("!")} ${agent.replace("_", " ")} setup failed: ${formatError(err)}`);
429
+ console.log(` ${dim("Other agents will continue. Re-run setup to retry.")}`);
430
+ }
431
+ }
432
+ if (failed.length > 0) {
433
+ console.log();
434
+ console.log(` ${yellow("!")} Failed: ${failed.join(", ")} ${dim("(others completed successfully)")}`);
339
435
  }
340
436
  }
341
437
  const sendTest = await confirm({ message: "Send a test notification?", default: true });
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleStop
4
- } from "../chunk-P4JH2Q7Z.js";
5
- import "../chunk-KTP2EPVB.js";
6
- import "../chunk-VIST7ACL.js";
4
+ } from "../chunk-KYARP7KP.js";
5
+ import "../chunk-4Z4MB37G.js";
6
+ import "../chunk-O6A5RHWY.js";
7
7
 
8
8
  // bin/pushary-stop-hook.ts
9
9
  var main = async () => {
@@ -0,0 +1,96 @@
1
+ import {
2
+ callMcpTool
3
+ } from "./chunk-O6A5RHWY.js";
4
+
5
+ // src/validate.ts
6
+ var isPolicyConfig = (data) => {
7
+ if (!data || typeof data !== "object") return false;
8
+ const d = data;
9
+ return Array.isArray(d.policies) && typeof d.defaultTimeoutSeconds === "number" && typeof d.defaultTimeoutAction === "string";
10
+ };
11
+ var isAskUserResponse = (data) => {
12
+ if (!data || typeof data !== "object") return false;
13
+ const d = data;
14
+ return typeof d.correlationId === "string" && typeof d.status === "string";
15
+ };
16
+ var isWaitForAnswerResponse = (data) => {
17
+ if (!data || typeof data !== "object") return false;
18
+ const d = data;
19
+ return typeof d.answered === "boolean";
20
+ };
21
+
22
+ // src/api.ts
23
+ var askUser = async (apiKey, params) => {
24
+ const result = await callMcpTool(apiKey, "ask_user", { ...params });
25
+ if (!isAskUserResponse(result)) throw new Error("Invalid ask_user response");
26
+ return result;
27
+ };
28
+ var waitForAnswer = async (apiKey, correlationId, timeoutMs = 3e4) => {
29
+ const result = await callMcpTool(apiKey, "wait_for_answer", {
30
+ correlationId,
31
+ timeoutMs
32
+ });
33
+ if (!isWaitForAnswerResponse(result)) throw new Error("Invalid wait_for_answer response");
34
+ return result;
35
+ };
36
+ var cancelQuestion = async (apiKey, correlationId) => {
37
+ await callMcpTool(apiKey, "cancel_question", { correlationId });
38
+ };
39
+ var sendNotification = async (apiKey, params) => {
40
+ await callMcpTool(apiKey, "send_notification", { ...params });
41
+ };
42
+
43
+ // src/describe.ts
44
+ var hookPrefixes = {
45
+ Bash: (input) => `bash: ${input.command ?? "(no command)"}`,
46
+ Write: (input) => `write file: ${input.file_path ?? "(unknown path)"}`,
47
+ Edit: (input) => `edit file: ${input.file_path ?? "(unknown path)"}`,
48
+ Read: (input) => `read file: ${input.file_path ?? "(unknown path)"}`
49
+ };
50
+ var eventPrefixes = {
51
+ Bash: (input) => `ran: ${String(input.command ?? "").slice(0, 120)}`,
52
+ Write: (input) => `wrote: ${input.file_path ?? "unknown"}`,
53
+ Edit: (input) => `edited: ${input.file_path ?? "unknown"}`,
54
+ Read: (input) => `read: ${input.file_path ?? "unknown"}`
55
+ };
56
+ var describeToolCall = (toolName, toolInput, format = "hook") => {
57
+ const prefixes = format === "hook" ? hookPrefixes : eventPrefixes;
58
+ const builder = prefixes[toolName];
59
+ if (builder) return builder(toolInput);
60
+ return format === "hook" ? `${toolName}: ${JSON.stringify(toolInput).slice(0, 200)}` : `${toolName}: done`;
61
+ };
62
+
63
+ // src/pending.ts
64
+ import { join } from "path";
65
+ import { tmpdir } from "os";
66
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from "fs";
67
+ var PENDING_DIR = join(tmpdir(), "pushary-pending");
68
+ var savePendingQuestion = (correlationId) => {
69
+ if (!existsSync(PENDING_DIR)) mkdirSync(PENDING_DIR, { recursive: true });
70
+ writeFileSync(join(PENDING_DIR, correlationId), "", "utf-8");
71
+ };
72
+ var listPendingQuestions = () => {
73
+ try {
74
+ return readdirSync(PENDING_DIR);
75
+ } catch {
76
+ return [];
77
+ }
78
+ };
79
+ var removePendingQuestion = (correlationId) => {
80
+ try {
81
+ unlinkSync(join(PENDING_DIR, correlationId));
82
+ } catch {
83
+ }
84
+ };
85
+
86
+ export {
87
+ isPolicyConfig,
88
+ askUser,
89
+ waitForAnswer,
90
+ cancelQuestion,
91
+ sendNotification,
92
+ describeToolCall,
93
+ savePendingQuestion,
94
+ listPendingQuestions,
95
+ removePendingQuestion
96
+ };
@@ -0,0 +1,97 @@
1
+ import {
2
+ cancelQuestion,
3
+ describeToolCall,
4
+ listPendingQuestions,
5
+ removePendingQuestion
6
+ } from "./chunk-4Z4MB37G.js";
7
+ import {
8
+ getApiKey,
9
+ getBaseUrl,
10
+ withRetry
11
+ } from "./chunk-O6A5RHWY.js";
12
+
13
+ // src/events.ts
14
+ import { hostname } from "os";
15
+ import { basename } from "path";
16
+ var cleanupPendingQuestions = async () => {
17
+ try {
18
+ const files = listPendingQuestions();
19
+ const apiKey = getApiKey();
20
+ for (const correlationId of files) {
21
+ try {
22
+ await cancelQuestion(apiKey, correlationId);
23
+ } catch {
24
+ }
25
+ removePendingQuestion(correlationId);
26
+ }
27
+ } catch {
28
+ }
29
+ };
30
+ var reportEvent = async (event) => {
31
+ const apiKey = getApiKey();
32
+ const baseUrl = getBaseUrl();
33
+ await withRetry(async () => {
34
+ await fetch(`${baseUrl}/api/agent/event`, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ "Authorization": `Bearer ${apiKey}`
39
+ },
40
+ body: JSON.stringify({
41
+ ...event,
42
+ machineId: event.machineId ?? hostname()
43
+ }),
44
+ signal: AbortSignal.timeout(1e4)
45
+ });
46
+ }, { maxAttempts: 2, baseDelayMs: 300 });
47
+ };
48
+ var handlePostToolUse = async (input) => {
49
+ try {
50
+ const projectName = basename(input.cwd ?? process.cwd());
51
+ const action = describeToolCall(input.tool_name, input.tool_input, "event");
52
+ const isError = input.tool_result && ("error" in input.tool_result || "is_error" in input.tool_result);
53
+ await Promise.allSettled([
54
+ cleanupPendingQuestions(),
55
+ reportEvent({
56
+ event: isError ? "tool_error" : "tool_complete",
57
+ agentType: "claude_code",
58
+ agentName: `Claude Code - ${projectName}`,
59
+ action,
60
+ error: isError ? String(input.tool_result?.error ?? input.tool_result?.stderr ?? "").slice(0, 500) : void 0
61
+ })
62
+ ]);
63
+ } catch {
64
+ }
65
+ };
66
+ var handleStop = async (input) => {
67
+ try {
68
+ const projectName = basename(input.cwd ?? process.cwd());
69
+ await reportEvent({
70
+ event: "session_end",
71
+ agentType: "claude_code",
72
+ agentName: `Claude Code - ${projectName}`,
73
+ action: "Session ended"
74
+ });
75
+ } catch {
76
+ }
77
+ };
78
+ var handleNotification = async (input) => {
79
+ try {
80
+ const projectName = basename(input.cwd ?? process.cwd());
81
+ await reportEvent({
82
+ event: input.type === "error" ? "error" : "notification",
83
+ agentType: "claude_code",
84
+ agentName: `Claude Code - ${projectName}`,
85
+ action: input.title ?? input.message ?? "Notification",
86
+ error: input.type === "error" ? input.message : void 0
87
+ });
88
+ } catch {
89
+ }
90
+ };
91
+
92
+ export {
93
+ reportEvent,
94
+ handlePostToolUse,
95
+ handleStop,
96
+ handleNotification
97
+ };
@@ -0,0 +1,122 @@
1
+ // src/config.ts
2
+ var getApiKey = () => {
3
+ const key = process.env.PUSHARY_API_KEY;
4
+ if (!key) {
5
+ throw new Error(
6
+ "PUSHARY_API_KEY environment variable is not set. Get your API key at https://pushary.com/sign-up?from=ai-coding"
7
+ );
8
+ }
9
+ return key;
10
+ };
11
+ var getBaseUrl = () => process.env.PUSHARY_BASE_URL ?? "https://pushary.com";
12
+
13
+ // src/retry.ts
14
+ var isRetryable = (err) => {
15
+ if (!(err instanceof Error)) return false;
16
+ const msg = err.message;
17
+ return /\b(502|503|429)\b/.test(msg) || /ECONNRESET|ECONNREFUSED|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|fetch failed/i.test(msg) || msg.includes("AbortError");
18
+ };
19
+ var withRetry = async (fn, options = {}) => {
20
+ const {
21
+ maxAttempts = 3,
22
+ baseDelayMs = 500,
23
+ maxDelayMs = 5e3,
24
+ shouldRetry = isRetryable
25
+ } = options;
26
+ let lastError;
27
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
28
+ try {
29
+ return await fn();
30
+ } catch (err) {
31
+ lastError = err;
32
+ if (attempt + 1 >= maxAttempts || !shouldRetry(err)) throw err;
33
+ const jitter = Math.random() * 0.3 + 0.85;
34
+ const delay = Math.min(baseDelayMs * 2 ** attempt * jitter, maxDelayMs);
35
+ await new Promise((r) => setTimeout(r, delay));
36
+ }
37
+ }
38
+ throw lastError;
39
+ };
40
+
41
+ // src/mcp-http.ts
42
+ var parseSseJson = (body) => {
43
+ const messages = [];
44
+ for (const event of body.split(/\r?\n\r?\n/)) {
45
+ const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n").trim();
46
+ if (!data) continue;
47
+ try {
48
+ messages.push(JSON.parse(data));
49
+ } catch {
50
+ }
51
+ }
52
+ const message = messages.at(-1);
53
+ if (!message) throw new Error("Empty response from Pushary");
54
+ return message;
55
+ };
56
+ var parseMcpResponse = (body, contentType) => {
57
+ if (contentType?.includes("text/event-stream")) {
58
+ return parseSseJson(body);
59
+ }
60
+ return JSON.parse(body);
61
+ };
62
+ var sendMcpRequest = async (apiKey, message, options = {}) => {
63
+ return withRetry(async () => {
64
+ const baseUrl = options.baseUrl ?? getBaseUrl();
65
+ const fetchFn = options.fetchFn ?? fetch;
66
+ const headers = {
67
+ "Content-Type": "application/json",
68
+ "Accept": "application/json, text/event-stream",
69
+ "Authorization": `Bearer ${apiKey}`
70
+ };
71
+ if (options.sessionId) {
72
+ headers["Mcp-Session-Id"] = options.sessionId;
73
+ }
74
+ const response = await fetchFn(`${baseUrl}/api/mcp/mcp`, {
75
+ method: "POST",
76
+ headers,
77
+ body: JSON.stringify(message),
78
+ signal: options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : void 0
79
+ });
80
+ const body = await response.text();
81
+ const contentType = response.headers.get("content-type");
82
+ let data = null;
83
+ if (body.trim()) {
84
+ try {
85
+ data = parseMcpResponse(body, contentType);
86
+ } catch (err) {
87
+ if (response.ok) throw err;
88
+ }
89
+ }
90
+ if (!response.ok) {
91
+ const msg = data?.error?.message ?? (body.trim() || response.statusText);
92
+ throw new Error(`Pushary MCP error: ${response.status} ${msg}`);
93
+ }
94
+ if (!data) throw new Error("Empty response from Pushary");
95
+ if (data.error) throw new Error(data.error.message ?? "Pushary MCP error");
96
+ return {
97
+ data,
98
+ sessionId: response.headers.get("mcp-session-id") ?? "",
99
+ status: response.status,
100
+ statusText: response.statusText
101
+ };
102
+ }, { maxAttempts: options.maxRetries ?? 1 });
103
+ };
104
+ var callMcpTool = async (apiKey, toolName, params, options = {}) => {
105
+ const { data } = await sendMcpRequest(apiKey, {
106
+ jsonrpc: "2.0",
107
+ id: options.id ?? Date.now(),
108
+ method: "tools/call",
109
+ params: { name: toolName, arguments: params }
110
+ }, options);
111
+ const text = data.result?.content?.[0]?.text;
112
+ if (!text) throw new Error("Empty response from Pushary");
113
+ return JSON.parse(text);
114
+ };
115
+
116
+ export {
117
+ getApiKey,
118
+ getBaseUrl,
119
+ withRetry,
120
+ sendMcpRequest,
121
+ callMcpTool
122
+ };
@@ -0,0 +1,219 @@
1
+ import {
2
+ askUser,
3
+ describeToolCall,
4
+ isPolicyConfig,
5
+ savePendingQuestion,
6
+ sendNotification,
7
+ waitForAnswer
8
+ } from "./chunk-4Z4MB37G.js";
9
+ import {
10
+ getApiKey,
11
+ getBaseUrl,
12
+ withRetry
13
+ } from "./chunk-O6A5RHWY.js";
14
+
15
+ // src/policy.ts
16
+ import { createHash } from "crypto";
17
+ import { existsSync, readFileSync, writeFileSync } from "fs";
18
+ import { join } from "path";
19
+ import { tmpdir } from "os";
20
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
21
+ var cacheFile = (apiKey) => {
22
+ const hash = createHash("sha256").update(apiKey).digest("hex").slice(0, 12);
23
+ return join(tmpdir(), `pushary-policy-${hash}.json`);
24
+ };
25
+ var fetchPolicy = async (apiKey) => {
26
+ return withRetry(async () => {
27
+ const baseUrl = getBaseUrl();
28
+ const response = await fetch(`${baseUrl}/api/mcp/policy`, {
29
+ headers: { "Authorization": `Bearer ${apiKey}` },
30
+ signal: AbortSignal.timeout(1e4)
31
+ });
32
+ if (!response.ok) {
33
+ throw new Error(`Failed to fetch policy: ${response.status}`);
34
+ }
35
+ const raw = await response.json();
36
+ if (!isPolicyConfig(raw)) throw new Error("Invalid policy response");
37
+ return raw;
38
+ }, { maxAttempts: 2 });
39
+ };
40
+ var getPolicy = async (apiKey) => {
41
+ const path = cacheFile(apiKey);
42
+ let staleCache = null;
43
+ if (existsSync(path)) {
44
+ try {
45
+ const stat = readFileSync(path, "utf-8");
46
+ const cached = JSON.parse(stat);
47
+ if (!isPolicyConfig(cached)) throw new Error("Corrupted cache");
48
+ if (!cached._cachedAt || Date.now() - cached._cachedAt < CACHE_TTL_MS) {
49
+ return cached;
50
+ }
51
+ staleCache = cached;
52
+ } catch {
53
+ }
54
+ }
55
+ try {
56
+ const policy = await fetchPolicy(apiKey);
57
+ try {
58
+ writeFileSync(path, JSON.stringify({ ...policy, _cachedAt: Date.now() }), "utf-8");
59
+ } catch {
60
+ }
61
+ return policy;
62
+ } catch {
63
+ if (staleCache) return staleCache;
64
+ throw new Error("Failed to fetch policy and no cached policy available");
65
+ }
66
+ };
67
+ var resolvePolicy = (config, toolName) => {
68
+ const exact = config.policies.find((p) => p.tool === toolName);
69
+ if (exact) return exact;
70
+ const wildcard = config.policies.find((p) => p.tool === "*");
71
+ if (wildcard) return wildcard;
72
+ return {
73
+ tool: toolName,
74
+ timeoutSeconds: config.defaultTimeoutSeconds,
75
+ timeoutAction: config.defaultTimeoutAction,
76
+ mode: config.defaultMode ?? "push_first",
77
+ pushFirstSeconds: config.defaultPushFirstSeconds ?? 10
78
+ };
79
+ };
80
+
81
+ // src/hook.ts
82
+ import { basename } from "path";
83
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
84
+ var allow = () => ({
85
+ hookSpecificOutput: {
86
+ hookEventName: "PreToolUse",
87
+ permissionDecision: "allow"
88
+ }
89
+ });
90
+ var deny = (reason) => ({
91
+ hookSpecificOutput: {
92
+ hookEventName: "PreToolUse",
93
+ permissionDecision: "deny",
94
+ permissionDecisionReason: reason
95
+ }
96
+ });
97
+ var ask = (reason) => ({
98
+ hookSpecificOutput: {
99
+ hookEventName: "PreToolUse",
100
+ permissionDecision: "ask",
101
+ ...reason ? { permissionDecisionReason: reason } : {}
102
+ }
103
+ });
104
+ var pollForAnswer = async (apiKey, correlationId, deadlineMs, pollInterval = 2e3) => {
105
+ while (Date.now() < deadlineMs) {
106
+ const remaining = Math.min(Math.max(deadlineMs - Date.now(), 1e3), 3e4);
107
+ let answer;
108
+ try {
109
+ answer = await waitForAnswer(apiKey, correlationId, remaining);
110
+ } catch {
111
+ if (Date.now() + pollInterval >= deadlineMs) break;
112
+ await sleep(pollInterval);
113
+ continue;
114
+ }
115
+ if (answer.answered) return answer;
116
+ if (Date.now() + pollInterval >= deadlineMs) break;
117
+ await sleep(pollInterval);
118
+ }
119
+ return { answered: false };
120
+ };
121
+ var handlePushOnly = async (apiKey, description, projectName, timeoutSeconds, timeoutAction) => {
122
+ let result;
123
+ try {
124
+ result = await askUser(apiKey, {
125
+ question: `Allow ${description}?`,
126
+ type: "confirm",
127
+ context: `Agent wants to run this in ${projectName}`,
128
+ agentName: `Claude Code - ${projectName}`
129
+ });
130
+ } catch {
131
+ switch (timeoutAction) {
132
+ case "approve":
133
+ return allow();
134
+ case "deny":
135
+ return deny("Push notification failed, denying per policy");
136
+ default:
137
+ return ask("Push notification failed, asking in terminal");
138
+ }
139
+ }
140
+ const deadline = Date.now() + timeoutSeconds * 1e3;
141
+ const answer = await pollForAnswer(apiKey, result.correlationId, deadline);
142
+ if (answer.answered) {
143
+ return answer.value === "yes" ? allow() : deny("Denied via push notification");
144
+ }
145
+ switch (timeoutAction) {
146
+ case "approve":
147
+ return allow();
148
+ case "deny":
149
+ return deny("No response within timeout");
150
+ default:
151
+ return ask("No push response, asking in terminal");
152
+ }
153
+ };
154
+ var handleTerminalOnly = () => {
155
+ return ask();
156
+ };
157
+ var handlePushFirst = async (apiKey, description, projectName, pushFirstSeconds) => {
158
+ let result;
159
+ try {
160
+ result = await askUser(apiKey, {
161
+ question: `Allow ${description}?`,
162
+ type: "confirm",
163
+ context: `Agent wants to run this in ${projectName}`,
164
+ agentName: `Claude Code - ${projectName}`
165
+ });
166
+ } catch {
167
+ return ask("Push notification failed, asking in terminal");
168
+ }
169
+ const deadline = Date.now() + pushFirstSeconds * 1e3;
170
+ const answer = await pollForAnswer(apiKey, result.correlationId, deadline, 1500);
171
+ if (answer.answered) {
172
+ return answer.value === "yes" ? allow() : deny("Denied via push notification");
173
+ }
174
+ savePendingQuestion(result.correlationId);
175
+ return ask("Sent as push notification. You can also approve here.");
176
+ };
177
+ var handleNotifyOnly = async (apiKey, description, projectName) => {
178
+ try {
179
+ await sendNotification(apiKey, {
180
+ title: "Agent needs approval",
181
+ body: description,
182
+ agentName: `Claude Code - ${projectName}`
183
+ });
184
+ } catch {
185
+ }
186
+ return ask();
187
+ };
188
+ var handlePreToolUse = async (input) => {
189
+ try {
190
+ const apiKey = getApiKey();
191
+ const policy = await getPolicy(apiKey);
192
+ const toolPolicy = resolvePolicy(policy, input.tool_name);
193
+ if (toolPolicy.timeoutSeconds === 0 && toolPolicy.timeoutAction === "approve") {
194
+ return allow();
195
+ }
196
+ const description = describeToolCall(input.tool_name, input.tool_input, "hook");
197
+ const projectName = basename(input.cwd ?? process.cwd());
198
+ switch (toolPolicy.mode) {
199
+ case "push_only":
200
+ return handlePushOnly(apiKey, description, projectName, toolPolicy.timeoutSeconds, toolPolicy.timeoutAction);
201
+ case "terminal_only":
202
+ return handleTerminalOnly();
203
+ case "push_first":
204
+ return handlePushFirst(apiKey, description, projectName, toolPolicy.pushFirstSeconds);
205
+ case "notify_only":
206
+ return handleNotifyOnly(apiKey, description, projectName);
207
+ default:
208
+ return handlePushFirst(apiKey, description, projectName, toolPolicy.pushFirstSeconds);
209
+ }
210
+ } catch {
211
+ return ask("Pushary unavailable, falling back to terminal approval");
212
+ }
213
+ };
214
+
215
+ export {
216
+ getPolicy,
217
+ resolvePolicy,
218
+ handlePreToolUse
219
+ };
@@ -11,7 +11,7 @@ interface HookOutput {
11
11
  permissionDecisionReason?: string;
12
12
  };
13
13
  }
14
- declare const handlePreToolUse: (input: ToolInput) => Promise<HookOutput | null>;
14
+ declare const handlePreToolUse: (input: ToolInput) => Promise<HookOutput>;
15
15
 
16
16
  interface AgentEvent {
17
17
  event: string;
package/dist/src/index.js CHANGED
@@ -2,22 +2,22 @@ import {
2
2
  getPolicy,
3
3
  handlePreToolUse,
4
4
  resolvePolicy
5
- } from "../chunk-DF3BM6BF.js";
5
+ } from "../chunk-WNXGIEX7.js";
6
6
  import {
7
7
  handleNotification,
8
8
  handlePostToolUse,
9
9
  handleStop,
10
10
  reportEvent
11
- } from "../chunk-P4JH2Q7Z.js";
11
+ } from "../chunk-KYARP7KP.js";
12
12
  import {
13
13
  askUser,
14
14
  cancelQuestion,
15
15
  waitForAnswer
16
- } from "../chunk-KTP2EPVB.js";
16
+ } from "../chunk-4Z4MB37G.js";
17
17
  import {
18
18
  getApiKey,
19
19
  getBaseUrl
20
- } from "../chunk-VIST7ACL.js";
20
+ } from "../chunk-O6A5RHWY.js";
21
21
  export {
22
22
  askUser,
23
23
  cancelQuestion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushary/agent-hooks",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Permission hooks for AI coding agents: route tool approvals through Pushary push notifications",
5
5
  "author": "Pushary <business@pushary.com>",
6
6
  "homepage": "https://pushary.com",
@@ -31,10 +31,11 @@
31
31
  "scripts": {
32
32
  "build": "tsup src/index.ts bin/pushary.ts bin/pushary-hook.ts bin/pushary-post-hook.ts bin/pushary-stop-hook.ts bin/pushary-codex.ts bin/pushary-setup.ts bin/pushary-clean.ts bin/pushary-doctor.ts --format esm --dts --outDir dist",
33
33
  "dev": "tsup src/index.ts bin/pushary.ts bin/pushary-hook.ts bin/pushary-post-hook.ts bin/pushary-stop-hook.ts bin/pushary-codex.ts bin/pushary-setup.ts bin/pushary-clean.ts bin/pushary-doctor.ts --format esm --watch",
34
- "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/mcp-http.test.ts"
34
+ "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/mcp-http.test.ts && bun test src/retry.test.ts && bun test src/validate.test.ts && bun test src/policy.test.ts && bun test src/events.test.ts && bun test src/hook.test.ts"
35
35
  },
36
36
  "dependencies": {
37
- "@inquirer/prompts": "^8.4.2"
37
+ "@inquirer/prompts": "^8.4.2",
38
+ "smol-toml": "^1.6.1"
38
39
  },
39
40
  "devDependencies": {
40
41
  "tsup": "^8.0.0",