@pushary/agent-hooks 0.28.2 → 0.29.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.
package/README.md CHANGED
@@ -22,7 +22,7 @@ There are three things.
22
22
 
23
23
  **Ask.** The agent asks you questions through push: yes/no, multiple choice, or free text. It waits for your answer. When Pushary is connected, the agent sends its questions to your phone instead of stalling in the terminal.
24
24
 
25
- **Gate.** Risky shell commands (`rm -rf`, force pushes, history rewrites, database drops, deploys) are checked before they run. What happens is set by your dashboard policy: auto approve trusted read-only commands, push to your phone for approval, or hold for the phone and never auto-deny. If the check cannot run, the command is blocked instead of allowed. It fails closed.
25
+ **Gate.** Risky shell commands (`rm -rf`, force pushes, history rewrites, database drops, deploys) are checked before they run. What happens is set by your dashboard policy: auto approve trusted read-only commands, push to your phone for approval, or hold for the phone and never auto-deny. If the check cannot run, the decision falls back to the agent's own permission prompt in the terminal; nothing is silently allowed. Without an API key configured, the hooks stay out of the way entirely and only count what they would have gated.
26
26
 
27
27
  ## Setup
28
28
 
@@ -62,8 +62,10 @@ Any other MCP client can use notify and ask by pointing at the Pushary MCP endpo
62
62
  setup Configure Claude Code, Codex, Gemini CLI, Hermes, or Cursor with Pushary
63
63
  doctor Verify your Pushary installation is working
64
64
  clean Remove all Pushary configuration
65
- mode Switch approval mode (push_only, push_first, terminal_only)
65
+ mode Switch approval mode (push_only, push_first, terminal_only, notify_only)
66
66
  wait Show or set the "wait for your phone" ladder (pushary wait 45)
67
+ stats Show the approval moments your agents hit while not connected
68
+ upgrade Update the globally installed hooks to the latest version
67
69
  hook Run as a PreToolUse hook (reads stdin, writes stdout)
68
70
  ```
69
71
 
@@ -97,7 +99,7 @@ The MCP server exposes `send_notification`, `ask_user`, `wait_for_answer`, and `
97
99
 
98
100
  ## Security
99
101
 
100
- The permission gate fails closed. If a policy cannot be fetched or a hook errors, the command is blocked, not allowed. Secrets in command arguments and environment are redacted before anything is sent to a phone or stored. Read-only commands can be allowlisted so trusted work never interrupts you, while writes and destructive commands still route through approval.
102
+ If a policy cannot be fetched or a hook errors, the decision falls back to the agent's native permission prompt, never a silent allow. The Cursor gate is the strictest surface: it blocks matched risky commands outright when its check cannot run. Secrets in command arguments and environment are redacted before anything is sent to a phone or stored. Read-only commands can be allowlisted so trusted work never interrupts you, while writes and destructive commands still route through approval.
101
103
 
102
104
  ## Links
103
105
 
@@ -4,11 +4,13 @@ import {
4
4
  removePusharySettings
5
5
  } from "../chunk-QNEYHDKR.js";
6
6
  import {
7
- execNpm,
8
7
  removeCodexHooks,
9
8
  removeGeminiSettings,
10
9
  removeInstructionBlock
11
- } from "../chunk-X3YCHQQK.js";
10
+ } from "../chunk-XY6OKUQ4.js";
11
+ import {
12
+ execNpm
13
+ } from "../chunk-RSHN2AQ7.js";
12
14
  import "../chunk-Z5PL3K7C.js";
13
15
 
14
16
  // bin/pushary-clean.ts
@@ -4,7 +4,6 @@ import {
4
4
  } from "../chunk-IIENZE2J.js";
5
5
  import {
6
6
  GEMINI_HOOK_BINARY,
7
- execNpm,
8
7
  hasCodexHooks,
9
8
  hasGeminiHooks,
10
9
  hasInstructionBlock,
@@ -12,7 +11,10 @@ import {
12
11
  missingGeminiHookEvents,
13
12
  readCodexMcpAuth,
14
13
  untrustedCodexHookEvents
15
- } from "../chunk-X3YCHQQK.js";
14
+ } from "../chunk-XY6OKUQ4.js";
15
+ import {
16
+ execNpm
17
+ } from "../chunk-RSHN2AQ7.js";
16
18
  import {
17
19
  callMcpTool,
18
20
  sendMcpRequest
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePreToolUse
4
- } from "../chunk-P6QBZS27.js";
4
+ } from "../chunk-GPLKEAFG.js";
5
+ import "../chunk-WLF3NCHS.js";
5
6
  import "../chunk-KQYIHZ5E.js";
6
7
  import "../chunk-IAOXM7X5.js";
7
8
  import "../chunk-DWED7BS3.js";
@@ -28,7 +29,7 @@ var main = async () => {
28
29
  }
29
30
  try {
30
31
  const output = await handlePreToolUse(input);
31
- process.stdout.write(JSON.stringify(output));
32
+ if (output) process.stdout.write(JSON.stringify(output));
32
33
  } catch (err) {
33
34
  process.stderr.write(
34
35
  `[pushary-hook] Error: ${err instanceof Error ? err.message : String(err)}
@@ -11,12 +11,14 @@ import {
11
11
  addCodexMcpServer,
12
12
  addGeminiHooks,
13
13
  addGeminiMcpServer,
14
- execNpm,
15
- npmErrorMessage,
16
14
  renderAgentInstructions,
17
15
  renderProjectAgentInstructions,
18
16
  writeInstructionBlock
19
- } from "../chunk-X3YCHQQK.js";
17
+ } from "../chunk-XY6OKUQ4.js";
18
+ import {
19
+ execNpm,
20
+ npmErrorMessage
21
+ } from "../chunk-RSHN2AQ7.js";
20
22
  import {
21
23
  reportEvent
22
24
  } from "../chunk-IAOXM7X5.js";
@@ -411,7 +413,7 @@ var checkForUpdates = async (current) => {
411
413
  const latest = data.version;
412
414
  if (latest && latest !== current) {
413
415
  console.log(` ${yellow2("!")} Update available: ${dim2(current)} \u2192 ${green2(latest)}`);
414
- console.log(` ${dim2("Run:")} npx @pushary/agent-hooks@${latest} setup`);
416
+ console.log(` ${dim2("Run:")} npx @pushary/agent-hooks@latest upgrade`);
415
417
  console.log();
416
418
  }
417
419
  } catch {
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ readLedgerSummary
4
+ } from "../chunk-WLF3NCHS.js";
5
+ import "../chunk-Z5PL3K7C.js";
6
+ import {
7
+ getApiKey
8
+ } from "../chunk-NKXSILEW.js";
9
+
10
+ // bin/pushary-stats.ts
11
+ var DAYS = 7;
12
+ var summary = readLedgerSummary(DAYS);
13
+ var connected = true;
14
+ try {
15
+ getApiKey();
16
+ } catch {
17
+ connected = false;
18
+ }
19
+ var plural = (count, word) => count === 1 ? word : `${word}s`;
20
+ if (summary.count === 0) {
21
+ if (connected) {
22
+ console.log("No locally counted approval moments in the last 7 days. Connected sessions land in your dashboard: https://pushary.com/dashboard/agent/activity");
23
+ } else {
24
+ console.log("No approval moments counted yet. Run your agent with the Pushary hooks installed and check back after a session or two.");
25
+ }
26
+ process.exit(0);
27
+ }
28
+ var toolLines = Object.entries(summary.byTool).sort((a, b) => b[1] - a[1]).map(([tool, count]) => ` ${tool}: ${count}`).join("\n");
29
+ console.log(`In the last ${DAYS} days, your agents hit ${summary.count} approval ${plural(summary.count, "moment")} across ${summary.sessions} ${plural(summary.sessions, "session")} while Pushary was not connected:
30
+ `);
31
+ console.log(toolLines);
32
+ console.log();
33
+ if (connected) {
34
+ console.log("A key is configured now, so new moments reach your phone instead of waiting in the terminal.");
35
+ } else {
36
+ console.log("Each of those sat in the terminal until you noticed. With Pushary they land on your lock screen as a yes or no.");
37
+ console.log("7-day trial: https://pushary.com/sign-up?utm_source=cli&utm_medium=stats");
38
+ }
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ execNpm,
4
+ npmErrorMessage
5
+ } from "../chunk-RSHN2AQ7.js";
6
+
7
+ // bin/pushary-upgrade.ts
8
+ var getInstalledVersion = () => {
9
+ try {
10
+ return execNpm("list -g --no-workspaces @pushary/agent-hooks --depth=0", { timeout: 1e4 }).toString().match(/@pushary\/agent-hooks@([\d.]+)/)?.[1] ?? "";
11
+ } catch {
12
+ return "";
13
+ }
14
+ };
15
+ var fetchLatestVersion = async () => {
16
+ try {
17
+ const res = await fetch("https://registry.npmjs.org/@pushary/agent-hooks/latest", {
18
+ signal: AbortSignal.timeout(5e3)
19
+ });
20
+ if (!res.ok) return void 0;
21
+ const data = await res.json();
22
+ return data.version;
23
+ } catch {
24
+ return void 0;
25
+ }
26
+ };
27
+ var isNewer = (a, b) => {
28
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
29
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
30
+ for (let i = 0; i < 3; i++) {
31
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
32
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
33
+ }
34
+ return false;
35
+ };
36
+ var main = async () => {
37
+ const installed = getInstalledVersion();
38
+ if (!installed) {
39
+ console.log("No global install of @pushary/agent-hooks found, so there is nothing to upgrade.");
40
+ console.log("Plugin installs update on their own. To set up the CLI hooks: npx @pushary/agent-hooks@latest setup");
41
+ return;
42
+ }
43
+ const latest = await fetchLatestVersion();
44
+ if (latest && !isNewer(latest, installed)) {
45
+ console.log(`Already on the latest version (${installed}).`);
46
+ return;
47
+ }
48
+ console.log(`Upgrading @pushary/agent-hooks ${installed} -> ${latest ?? "latest"}`);
49
+ try {
50
+ execNpm("install -g --no-workspaces @pushary/agent-hooks@latest", { timeout: 12e4 });
51
+ } catch (err) {
52
+ console.error(`Upgrade failed: ${npmErrorMessage(err)}`);
53
+ process.exit(1);
54
+ }
55
+ console.log("Done. Your hooks point at the global install, so agents pick this up on their next tool call.");
56
+ console.log("Verify with: pushary doctor");
57
+ };
58
+ main();
@@ -14,6 +14,10 @@ if (command === "setup") {
14
14
  await import("./pushary-mode.js");
15
15
  } else if (command === "wait") {
16
16
  await import("./pushary-wait.js");
17
+ } else if (command === "stats") {
18
+ await import("./pushary-stats.js");
19
+ } else if (command === "upgrade") {
20
+ await import("./pushary-upgrade.js");
17
21
  } else {
18
22
  console.log(`
19
23
  Pushary Agent Hooks
@@ -24,6 +28,8 @@ Commands:
24
28
  clean Remove all Pushary configuration
25
29
  mode Switch approval mode (push_only, push_first, terminal_only)
26
30
  wait Show or set the "wait for your phone" ladder (pushary wait 45)
31
+ stats Show the approval moments your agents hit while not connected
32
+ upgrade Update the globally installed hooks to the latest version
27
33
  hook Run as a PreToolUse hook (reads stdin, writes stdout)
28
34
 
29
35
  Setup options:
@@ -1,3 +1,7 @@
1
+ import {
2
+ isGatingMoment,
3
+ recordKeylessMoment
4
+ } from "./chunk-WLF3NCHS.js";
1
5
  import {
2
6
  denyReasonFrom,
3
7
  isDeferAnswer
@@ -31,7 +35,9 @@ import {
31
35
  } from "./chunk-NKXSILEW.js";
32
36
 
33
37
  // src/hook.ts
34
- import { basename } from "path";
38
+ import { basename, join } from "path";
39
+ import { tmpdir } from "os";
40
+ import { existsSync, writeFileSync } from "fs";
35
41
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
36
42
  var START_MS = Date.now();
37
43
  var allow = () => ({
@@ -182,9 +188,41 @@ var handleNotifyOnly = async (apiKey, description, projectName, sessionId, machi
182
188
  }
183
189
  return ask();
184
190
  };
191
+ var KEYLESS_NOTICE = "[pushary] Not connected (no API key), so your agent runs untouched. Approval moments are counted locally; see them with: npx @pushary/agent-hooks@latest stats. Connect: https://pushary.com/sign-up?utm_source=cli&utm_medium=keyless-hook";
192
+ var keylessNoticeOnce = (sessionId) => {
193
+ try {
194
+ const key = (sessionId || DEFAULT_SESSION).replace(/[^a-zA-Z0-9_-]/g, "");
195
+ const marker = join(tmpdir(), `pushary-keyless-${key}`);
196
+ if (existsSync(marker)) return;
197
+ writeFileSync(marker, "1", "utf-8");
198
+ process.stderr.write(`${KEYLESS_NOTICE}
199
+ `);
200
+ } catch {
201
+ }
202
+ };
203
+ var handleKeyless = (input) => {
204
+ try {
205
+ if (isGatingMoment(input.tool_name, input.tool_input ?? {})) {
206
+ recordKeylessMoment({
207
+ ts: Date.now(),
208
+ tool: input.tool_name,
209
+ project: basename(input.cwd ?? process.cwd()),
210
+ sessionId: input.session_id
211
+ });
212
+ }
213
+ keylessNoticeOnce(input.session_id);
214
+ } catch {
215
+ }
216
+ return void 0;
217
+ };
185
218
  var handlePreToolUse = async (input) => {
219
+ let apiKey;
220
+ try {
221
+ apiKey = getApiKey();
222
+ } catch {
223
+ return handleKeyless(input);
224
+ }
186
225
  try {
187
- const apiKey = getApiKey();
188
226
  const modeState = await fetchModeState(apiKey, input.session_id);
189
227
  const policy = await getPolicy(apiKey, modeState.policyVersion);
190
228
  if (modeState.kill) {
@@ -0,0 +1,29 @@
1
+ // src/npm.ts
2
+ import { execSync } from "child_process";
3
+ var cleanNpmEnv = () => {
4
+ const env = {};
5
+ for (const [key, value] of Object.entries(process.env)) {
6
+ if (key.toLowerCase().startsWith("npm_config_workspace")) continue;
7
+ env[key] = value;
8
+ }
9
+ return env;
10
+ };
11
+ var npmErrorMessage = (err) => {
12
+ const e = err;
13
+ const text = [e?.stderr, e?.stdout].map((part) => part ? part.toString() : "").join("\n");
14
+ const line = text.split("\n").map((l) => l.replace(/^npm error\s*/i, "").trim()).find((l) => l && !l.startsWith("A complete log") && !/^code\s/i.test(l));
15
+ return line || e?.message || String(err);
16
+ };
17
+ var execNpm = (args, options = {}) => {
18
+ return execSync(`npm ${args}`, {
19
+ timeout: 12e4,
20
+ stdio: "pipe",
21
+ ...options,
22
+ env: { ...cleanNpmEnv(), ...options.env ?? {} }
23
+ });
24
+ };
25
+
26
+ export {
27
+ npmErrorMessage,
28
+ execNpm
29
+ };
@@ -0,0 +1,62 @@
1
+ import {
2
+ isSafeReadOnlyCommand
3
+ } from "./chunk-Z5PL3K7C.js";
4
+
5
+ // src/ledger.ts
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "fs";
7
+ import { join, dirname } from "path";
8
+ import { homedir } from "os";
9
+ var ledgerPath = () => process.env.PUSHARY_LEDGER_FILE?.trim() || join(homedir(), ".pushary", "local-ledger.jsonl");
10
+ var MAX_LEDGER_BYTES = 1e6;
11
+ var isGatingMoment = (toolName, toolInput) => {
12
+ const command = typeof toolInput?.command === "string" ? toolInput.command : void 0;
13
+ if (toolName === "Bash" && command && isSafeReadOnlyCommand(command)) return false;
14
+ return true;
15
+ };
16
+ var recordKeylessMoment = (entry) => {
17
+ try {
18
+ const path = ledgerPath();
19
+ const dir = dirname(path);
20
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
21
+ if (existsSync(path) && statSync(path).size > MAX_LEDGER_BYTES) {
22
+ rmSync(`${path}.1`, { force: true });
23
+ renameSync(path, `${path}.1`);
24
+ }
25
+ appendFileSync(path, `${JSON.stringify(entry)}
26
+ `, "utf-8");
27
+ } catch {
28
+ }
29
+ };
30
+ var readLedgerSummary = (days = 7) => {
31
+ const sinceMs = Date.now() - days * 864e5;
32
+ const summary = { count: 0, sessions: 0, byTool: {}, sinceMs };
33
+ let raw;
34
+ try {
35
+ raw = readFileSync(ledgerPath(), "utf-8");
36
+ } catch {
37
+ return summary;
38
+ }
39
+ const sessions = /* @__PURE__ */ new Set();
40
+ for (const line of raw.split("\n")) {
41
+ if (!line.trim()) continue;
42
+ let entry;
43
+ try {
44
+ entry = JSON.parse(line);
45
+ } catch {
46
+ continue;
47
+ }
48
+ if (typeof entry.ts !== "number" || entry.ts < sinceMs) continue;
49
+ summary.count += 1;
50
+ const tool = typeof entry.tool === "string" && entry.tool ? entry.tool : "Other";
51
+ summary.byTool[tool] = (summary.byTool[tool] ?? 0) + 1;
52
+ sessions.add(entry.sessionId || `${entry.project ?? "unknown"}:no-session`);
53
+ }
54
+ summary.sessions = sessions.size;
55
+ return summary;
56
+ };
57
+
58
+ export {
59
+ isGatingMoment,
60
+ recordKeylessMoment,
61
+ readLedgerSummary
62
+ };
@@ -307,31 +307,6 @@ var hasInstructionBlock = (filePath) => {
307
307
  }
308
308
  };
309
309
 
310
- // src/npm.ts
311
- import { execSync } from "child_process";
312
- var cleanNpmEnv = () => {
313
- const env = {};
314
- for (const [key, value] of Object.entries(process.env)) {
315
- if (key.toLowerCase().startsWith("npm_config_workspace")) continue;
316
- env[key] = value;
317
- }
318
- return env;
319
- };
320
- var npmErrorMessage = (err) => {
321
- const e = err;
322
- const text = [e?.stderr, e?.stdout].map((part) => part ? part.toString() : "").join("\n");
323
- const line = text.split("\n").map((l) => l.replace(/^npm error\s*/i, "").trim()).find((l) => l && !l.startsWith("A complete log") && !/^code\s/i.test(l));
324
- return line || e?.message || String(err);
325
- };
326
- var execNpm = (args, options = {}) => {
327
- return execSync(`npm ${args}`, {
328
- timeout: 12e4,
329
- stdio: "pipe",
330
- ...options,
331
- env: { ...cleanNpmEnv(), ...options.env ?? {} }
332
- });
333
- };
334
-
335
310
  export {
336
311
  addCodexMcpServer,
337
312
  readCodexMcpAuth,
@@ -351,7 +326,5 @@ export {
351
326
  renderProjectAgentInstructions,
352
327
  writeInstructionBlock,
353
328
  removeInstructionBlock,
354
- hasInstructionBlock,
355
- npmErrorMessage,
356
- execNpm
329
+ hasInstructionBlock
357
330
  };
@@ -15,7 +15,7 @@ interface HookOutput {
15
15
  permissionDecisionReason?: string;
16
16
  };
17
17
  }
18
- declare const handlePreToolUse: (input: ToolInput) => Promise<HookOutput>;
18
+ declare const handlePreToolUse: (input: ToolInput) => Promise<HookOutput | undefined>;
19
19
 
20
20
  type ReceiptKind = 'edit' | 'write' | 'bash' | 'commit';
21
21
  interface ReceiptMeta {
package/dist/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  handlePreToolUse
3
- } from "../chunk-P6QBZS27.js";
3
+ } from "../chunk-GPLKEAFG.js";
4
+ import "../chunk-WLF3NCHS.js";
4
5
  import "../chunk-KQYIHZ5E.js";
5
6
  import {
6
7
  askUser,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushary/agent-hooks",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "Permission hooks for AI coding agents: route tool approvals through Pushary push notifications",
5
5
  "keywords": [
6
6
  "pushary",
@@ -54,7 +54,9 @@
54
54
  "pushary-clean": "./dist/bin/pushary-clean.js",
55
55
  "pushary-doctor": "./dist/bin/pushary-doctor.js",
56
56
  "pushary-mode": "./dist/bin/pushary-mode.js",
57
- "pushary-wait": "./dist/bin/pushary-wait.js"
57
+ "pushary-wait": "./dist/bin/pushary-wait.js",
58
+ "pushary-stats": "./dist/bin/pushary-stats.js",
59
+ "pushary-upgrade": "./dist/bin/pushary-upgrade.js"
58
60
  },
59
61
  "files": [
60
62
  "dist",
@@ -63,7 +65,7 @@
63
65
  "scripts": {
64
66
  "build": "node scripts/bundle-plugin.mjs && tsup",
65
67
  "dev": "tsup --watch",
66
- "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/config.test.ts && bun test src/mcp-http.test.ts && bun test src/retry.test.ts && bun test src/usage.test.ts && bun test src/validate.test.ts && bun test src/policy.test.ts && bun test src/npm.test.ts && bun test src/identity.test.ts && bun test src/pending.test.ts && bun test src/events.test.ts && bun test src/describe.test.ts && bun test src/suggestions.test.ts && bun test src/safe-commands.test.ts && bun test src/hook.test.ts && bun test src/codex-adapter.test.ts && bun test src/codex-config.test.ts && bun test src/gemini-adapter.test.ts && bun test src/gemini-config.test.ts && bun test src/instruction-file.test.ts && bun test src/pairing.test.ts && bun test src/wait-ladder.test.ts"
68
+ "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/config.test.ts && bun test src/mcp-http.test.ts && bun test src/retry.test.ts && bun test src/usage.test.ts && bun test src/validate.test.ts && bun test src/policy.test.ts && bun test src/npm.test.ts && bun test src/identity.test.ts && bun test src/pending.test.ts && bun test src/events.test.ts && bun test src/describe.test.ts && bun test src/suggestions.test.ts && bun test src/safe-commands.test.ts && bun test src/hook.test.ts && bun test src/codex-adapter.test.ts && bun test src/codex-config.test.ts && bun test src/gemini-adapter.test.ts && bun test src/gemini-config.test.ts && bun test src/instruction-file.test.ts && bun test src/pairing.test.ts && bun test src/wait-ladder.test.ts && bun test src/ledger.test.ts"
67
69
  },
68
70
  "dependencies": {
69
71
  "@inquirer/prompts": "^8.4.2",