@pushary/agent-hooks 0.28.3 → 0.30.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
 
@@ -2,19 +2,22 @@
2
2
  import {
3
3
  removeClaudeMcpServers,
4
4
  removePusharySettings
5
- } from "../chunk-QNEYHDKR.js";
5
+ } from "../chunk-H3LQRYMW.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-J7JWI3KU.js";
12
14
  import "../chunk-Z5PL3K7C.js";
13
15
 
14
16
  // bin/pushary-clean.ts
15
- import { existsSync, readFileSync, writeFileSync, rmSync } from "fs";
17
+ import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync } from "fs";
16
18
  import { join } from "path";
17
- import { homedir } from "os";
19
+ import { homedir, tmpdir } from "os";
20
+ import { execSync } from "child_process";
18
21
  import { confirm } from "@inquirer/prompts";
19
22
  import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
20
23
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
@@ -29,7 +32,25 @@ var CLAUDE_JSON = join(homedir(), ".claude.json");
29
32
  var SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
30
33
  var CURSOR_MCP = join(".cursor", "mcp.json");
31
34
  var CURSOR_PLUGIN_DIR = join(homedir(), ".cursor", "plugins", "local", "pushary");
35
+ var CURSOR_USER_HOOKS = join(homedir(), ".cursor", "hooks.json");
36
+ var PUSHARY_DIR = join(homedir(), ".pushary");
32
37
  var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
38
+ var resolveHermesPython = () => {
39
+ try {
40
+ const launcher = execSync("command -v hermes", { encoding: "utf-8", stdio: "pipe", timeout: 5e3 }).trim();
41
+ if (launcher) {
42
+ const shebang = readFileSync(launcher, "utf-8").split("\n", 1)[0];
43
+ if (shebang.startsWith("#!")) {
44
+ const interpreter = shebang.slice(2).trim().split(/\s+/)[0];
45
+ if (interpreter && /python/i.test(interpreter) && existsSync(interpreter)) return interpreter;
46
+ }
47
+ }
48
+ } catch {
49
+ }
50
+ const venvPython = join(homedir(), ".hermes", "hermes-agent", "venv", "bin", "python3");
51
+ if (existsSync(venvPython)) return venvPython;
52
+ return null;
53
+ };
33
54
  var readJson = (path) => {
34
55
  try {
35
56
  return JSON.parse(readFileSync(path, "utf-8"));
@@ -59,7 +80,12 @@ var main = async () => {
59
80
  console.log(` ${bold("Pushary Clean")}`);
60
81
  console.log(` ${dim("Removes all Pushary configuration")}`);
61
82
  console.log();
62
- const proceed = await confirm({ message: "Remove all Pushary configuration?", default: false });
83
+ const assumeYes = process.argv.includes("--yes") || process.argv.includes("-y");
84
+ if (!assumeYes && !process.stdin.isTTY) {
85
+ console.log(` ${yellow("!")} Non-interactive shell. Re-run with --yes to confirm removal.`);
86
+ process.exit(1);
87
+ }
88
+ const proceed = assumeYes || await confirm({ message: "Remove all Pushary configuration?", default: false });
63
89
  if (!proceed) {
64
90
  console.log(` ${dim("Cancelled.")}`);
65
91
  process.exit(0);
@@ -97,6 +123,23 @@ var main = async () => {
97
123
  } else {
98
124
  console.log(` ${skip} Cursor plugin ${dim("(not installed)")}`);
99
125
  }
126
+ const cursorHooks = readJson(CURSOR_USER_HOOKS);
127
+ if (cursorHooks) {
128
+ const hooks = cursorHooks.hooks ?? {};
129
+ const existing = Array.isArray(hooks.beforeShellExecution) ? hooks.beforeShellExecution : [];
130
+ const others = existing.filter((h) => !String(h.command ?? "").includes("pushary-gate"));
131
+ if (others.length !== existing.length) {
132
+ if (others.length === 0) delete hooks.beforeShellExecution;
133
+ else hooks.beforeShellExecution = others;
134
+ cursorHooks.hooks = hooks;
135
+ writeJson(CURSOR_USER_HOOKS, cursorHooks);
136
+ console.log(` ${check} Cursor gate ${dim("(removed from ~/.cursor/hooks.json)")}`);
137
+ } else {
138
+ console.log(` ${skip} Cursor gate ${dim("(no pushary entries)")}`);
139
+ }
140
+ } else {
141
+ console.log(` ${skip} Cursor gate ${dim("(no hooks.json)")}`);
142
+ }
100
143
  if (existsSync(SKILL_DIR)) {
101
144
  rmSync(SKILL_DIR, { recursive: true });
102
145
  console.log(` ${check} Skill directory ${dim("(removed)")}`);
@@ -168,6 +211,24 @@ var main = async () => {
168
211
  } else {
169
212
  console.log(` ${skip} Gemini GEMINI.md ${dim("(no pushary block)")}`);
170
213
  }
214
+ const hermesPython = resolveHermesPython();
215
+ if (hermesPython) {
216
+ const snippet = 'from hermes_cli.config import load_config, save_config; c = load_config(); p = c.get("plugins") if isinstance(c.get("plugins"), dict) else {}; e = p.get("enabled") if isinstance(p.get("enabled"), list) else []; p["enabled"] = [x for x in e if x != "pushary"]; c["plugins"] = p; a = c.get("agent") if isinstance(c.get("agent"), dict) else {}; d = a.get("disabled_toolsets") if isinstance(a.get("disabled_toolsets"), list) else []; a["disabled_toolsets"] = [x for x in d if x != "clarify"]; c["agent"] = a; save_config(c)';
217
+ try {
218
+ execSync(`"${hermesPython}" -c '${snippet}'`, { stdio: "pipe", timeout: 15e3 });
219
+ console.log(` ${check} Hermes config ${dim("(plugin disabled, clarify toolset restored)")}`);
220
+ } catch {
221
+ console.log(` ${skip} Hermes config ${dim("(could not update config.yaml)")}`);
222
+ }
223
+ try {
224
+ execSync(`"${hermesPython}" -m pip uninstall -y hermes-plugin-pushary`, { stdio: "pipe", timeout: 6e4 });
225
+ console.log(` ${check} Hermes plugin ${dim("(pip uninstalled)")}`);
226
+ } catch {
227
+ console.log(` ${skip} Hermes plugin ${dim("(not installed)")}`);
228
+ }
229
+ } else {
230
+ console.log(` ${skip} Hermes ${dim("(not found)")}`);
231
+ }
171
232
  for (const shellFile of SHELL_FILES) {
172
233
  try {
173
234
  const content = readFileSync(shellFile, "utf-8");
@@ -179,6 +240,25 @@ var main = async () => {
179
240
  } catch {
180
241
  }
181
242
  }
243
+ if (existsSync(PUSHARY_DIR)) {
244
+ rmSync(PUSHARY_DIR, { recursive: true });
245
+ console.log(` ${check} Local state ${dim("(~/.pushary removed: stored API key, ledger)")}`);
246
+ } else {
247
+ console.log(` ${skip} Local state ${dim("(~/.pushary not found)")}`);
248
+ }
249
+ try {
250
+ let swept = 0;
251
+ for (const f of readdirSync(tmpdir())) {
252
+ if (!f.startsWith("pushary-")) continue;
253
+ try {
254
+ rmSync(join(tmpdir(), f), { recursive: true, force: true });
255
+ swept++;
256
+ } catch {
257
+ }
258
+ }
259
+ if (swept > 0) console.log(` ${check} Temp state ${dim(`(${swept} pushary entries swept)`)}`);
260
+ } catch {
261
+ }
182
262
  try {
183
263
  execNpm("uninstall -g --no-workspaces @pushary/agent-hooks", { stdio: "ignore", timeout: 3e4 });
184
264
  console.log(` ${check} Global package ${dim("(uninstalled)")}`);
@@ -3,6 +3,10 @@ import {
3
3
  denyReasonFrom,
4
4
  isDeferAnswer
5
5
  } from "../chunk-KQYIHZ5E.js";
6
+ import {
7
+ isGatingMoment,
8
+ recordKeylessMoment
9
+ } from "../chunk-R5AJNXZS.js";
6
10
  import {
7
11
  CODEX_AGENT,
8
12
  DEFAULT_SESSION,
@@ -201,8 +205,25 @@ var decidePermissionRequest = async (input) => {
201
205
  }
202
206
  };
203
207
  var decidePreToolUse = async (input) => {
208
+ let apiKey;
209
+ try {
210
+ apiKey = getApiKey();
211
+ } catch {
212
+ try {
213
+ const lookup = toPolicyLookup(input.tool_name ?? "", input.tool_input ?? {}, input.cwd);
214
+ if (isGatingMoment(lookup.tool, lookup.input)) {
215
+ recordKeylessMoment({
216
+ ts: Date.now(),
217
+ tool: lookup.tool,
218
+ project: basename(input.cwd ?? process.cwd()),
219
+ sessionId: input.session_id
220
+ });
221
+ }
222
+ } catch {
223
+ }
224
+ return codexPass();
225
+ }
204
226
  try {
205
- const apiKey = getApiKey();
206
227
  const modeState = await fetchModeState(apiKey, input.session_id);
207
228
  if (modeState.kill) return codexDeny(KILL_REASON);
208
229
  const lookup = toPolicyLookup(input.tool_name ?? "", input.tool_input ?? {}, input.cwd);
@@ -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,13 @@ 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-J7JWI3KU.js";
18
+ import {
19
+ readLedgerSummary
20
+ } from "../chunk-R5AJNXZS.js";
16
21
  import {
17
22
  callMcpTool,
18
23
  sendMcpRequest
@@ -315,12 +320,29 @@ var main = async () => {
315
320
  globalVersion = "";
316
321
  }
317
322
  check(!!globalVersion, "Global package installed", globalVersion || "not found");
323
+ if (globalVersion) {
324
+ try {
325
+ const res = await fetch("https://registry.npmjs.org/@pushary/agent-hooks/latest", {
326
+ signal: AbortSignal.timeout(5e3)
327
+ });
328
+ const latest = res.ok ? (await res.json()).version ?? "" : "";
329
+ if (latest) {
330
+ const upToDate = latest === globalVersion;
331
+ check(upToDate, "Global package up to date", upToDate ? globalVersion : `${globalVersion} installed, ${latest} available \u2014 run npx @pushary/agent-hooks@latest upgrade`);
332
+ }
333
+ } catch {
334
+ }
335
+ }
318
336
  console.log();
319
337
  console.log(` ${dim("Connectivity")}`);
320
338
  if (!apiKey) {
321
339
  check(false, "MCP server reachable", "skipped \u2014 no API key");
322
340
  check(false, "API key valid", "skipped");
323
341
  check(false, "MCP handshake", "skipped");
342
+ const keylessSummary = readLedgerSummary(7);
343
+ if (keylessSummary.count > 0) {
344
+ check(false, "Keyless approval moments (7d)", `${keylessSummary.count} counted locally \u2014 npx @pushary/agent-hooks@latest stats`);
345
+ }
324
346
  } else {
325
347
  let sessionId = "";
326
348
  try {
@@ -377,7 +399,7 @@ var main = async () => {
377
399
  const msg = err instanceof Error ? err.message : "network error";
378
400
  check(false, "Push notification sent", msg);
379
401
  }
380
- const testQuestion = await confirm({ message: "Test question roundtrip? (sends a push notification)", default: false });
402
+ const testQuestion = process.stdin.isTTY ? await confirm({ message: "Test question roundtrip? (sends a push notification)", default: false }) : false;
381
403
  if (testQuestion) {
382
404
  console.log();
383
405
  console.log(` ${dim("Question Roundtrip")}`);
@@ -416,7 +438,8 @@ var main = async () => {
416
438
  if (apiKey) {
417
439
  try {
418
440
  const res = await fetch(`${getBaseUrl()}/api/mcp/policy`, {
419
- headers: { Authorization: `Bearer ${apiKey}` }
441
+ headers: { Authorization: `Bearer ${apiKey}` },
442
+ signal: AbortSignal.timeout(1e4)
420
443
  });
421
444
  if (res.ok) {
422
445
  const policy = await res.json();
@@ -451,4 +474,7 @@ var main = async () => {
451
474
  }
452
475
  console.log();
453
476
  };
454
- main();
477
+ main().catch((err) => {
478
+ console.error(` doctor failed: ${err instanceof Error ? err.message : String(err)}`);
479
+ process.exit(1);
480
+ });
@@ -3,6 +3,10 @@ import {
3
3
  denyReasonFrom,
4
4
  isDeferAnswer
5
5
  } from "../chunk-KQYIHZ5E.js";
6
+ import {
7
+ isGatingMoment,
8
+ recordKeylessMoment
9
+ } from "../chunk-R5AJNXZS.js";
6
10
  import {
7
11
  DEFAULT_SESSION,
8
12
  askUser,
@@ -181,8 +185,25 @@ var handlePushFirst = async (apiKey, input, lookup, pushFirstSeconds) => {
181
185
  return geminiPass();
182
186
  };
183
187
  var decideBeforeTool = async (input) => {
188
+ let apiKey;
189
+ try {
190
+ apiKey = getApiKey();
191
+ } catch {
192
+ try {
193
+ const lookup = toGeminiPolicyLookup(input.tool_name ?? "", input.tool_input ?? {});
194
+ if (isGatingMoment(lookup.tool, lookup.input)) {
195
+ recordKeylessMoment({
196
+ ts: Date.now(),
197
+ tool: lookup.tool,
198
+ project: basename(input.cwd ?? process.cwd()),
199
+ sessionId: input.session_id
200
+ });
201
+ }
202
+ } catch {
203
+ }
204
+ return geminiPass();
205
+ }
184
206
  try {
185
- const apiKey = getApiKey();
186
207
  const modeState = await fetchModeState(apiKey, input.session_id);
187
208
  if (modeState.kill) return geminiDeny(KILL_REASON);
188
209
  const lookup = toGeminiPolicyLookup(input.tool_name ?? "", input.tool_input ?? {});
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePreToolUse
4
- } from "../chunk-P6QBZS27.js";
4
+ } from "../chunk-CY5YP34L.js";
5
5
  import "../chunk-KQYIHZ5E.js";
6
+ import "../chunk-R5AJNXZS.js";
6
7
  import "../chunk-IAOXM7X5.js";
7
8
  import "../chunk-DWED7BS3.js";
8
9
  import "../chunk-Z5PL3K7C.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)}
@@ -3,7 +3,7 @@ import {
3
3
  addClaudeMcpServer,
4
4
  addPusharyHooks,
5
5
  addPusharyToolPermissions
6
- } from "../chunk-QNEYHDKR.js";
6
+ } from "../chunk-H3LQRYMW.js";
7
7
  import {
8
8
  GEMINI_HOOK_BINARY,
9
9
  addCodexHookTrust,
@@ -11,12 +11,17 @@ 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
+ quoteIfNeeded,
22
+ resolveGlobalBinDir,
23
+ resolveGlobalBinary
24
+ } from "../chunk-J7JWI3KU.js";
20
25
  import {
21
26
  reportEvent
22
27
  } from "../chunk-IAOXM7X5.js";
@@ -411,7 +416,7 @@ var checkForUpdates = async (current) => {
411
416
  const latest = data.version;
412
417
  if (latest && latest !== current) {
413
418
  console.log(` ${yellow2("!")} Update available: ${dim2(current)} \u2192 ${green2(latest)}`);
414
- console.log(` ${dim2("Run:")} npx @pushary/agent-hooks@${latest} setup`);
419
+ console.log(` ${dim2("Run:")} npx @pushary/agent-hooks@latest upgrade`);
415
420
  console.log();
416
421
  }
417
422
  } catch {
@@ -461,13 +466,7 @@ var setupClaudeCode = async (apiKey) => {
461
466
  });
462
467
  await installGlobally();
463
468
  await spinner("Adding hooks (PreToolUse, PostToolUse, UserPromptSubmit, Stop)", async () => {
464
- let binDir;
465
- try {
466
- binDir = join(execNpm("prefix -g --no-workspaces", { timeout: 5e3 }).toString().trim(), "bin");
467
- } catch {
468
- binDir = void 0;
469
- }
470
- addPusharyHooks(settings, binDir);
469
+ addPusharyHooks(settings, resolveGlobalBinDir("pushary-hook"));
471
470
  });
472
471
  await spinner(`Writing ${CLAUDE_SETTINGS}`, async () => {
473
472
  writeJson(CLAUDE_SETTINGS, settings);
@@ -589,9 +588,7 @@ var removeCodexNotifyEntry = (codexConfig) => {
589
588
  writeFileSync(codexConfig, stringifyTOML(config), "utf-8");
590
589
  };
591
590
  var addCodexNotifyEntry = (codexConfig) => {
592
- const globalPrefix = execNpm("prefix -g --no-workspaces", { timeout: 5e3 }).toString().trim();
593
- const pusharyCodexPath = join(globalPrefix, "bin", "pushary-codex");
594
- if (!existsSync(pusharyCodexPath)) throw new Error("pushary-codex not found at " + pusharyCodexPath);
591
+ const pusharyCodexPath = resolveGlobalBinary("pushary-codex") ?? "pushary-codex";
595
592
  let raw = "";
596
593
  try {
597
594
  raw = readFileSync(codexConfig, "utf-8");
@@ -631,10 +628,8 @@ var setupCodex = async (apiKey) => {
631
628
  const trustAuto = codexTrustAutoSupported(codexVersion);
632
629
  let trusted = false;
633
630
  if (hooksSupported) {
634
- const globalPrefix = execNpm("prefix -g --no-workspaces", { timeout: 5e3 }).toString().trim();
635
- const hookCommand = join(globalPrefix, "bin", "pushary-codex-hook");
631
+ const hookCommand = quoteIfNeeded(resolveGlobalBinary("pushary-codex-hook") ?? "pushary-codex-hook");
636
632
  await spinner("Adding native hooks (~/.codex/hooks.json)", async () => {
637
- if (!existsSync(hookCommand)) throw new Error("pushary-codex-hook not found at " + hookCommand);
638
633
  const hooksConfig = readJson(CODEX_HOOKS_JSON);
639
634
  addCodexHooks(hooksConfig, hookCommand);
640
635
  writeJson(CODEX_HOOKS_JSON, hooksConfig);
@@ -820,13 +815,7 @@ var setupGemini = async (apiKey) => {
820
815
  addGeminiMcpServer(settings, apiKey);
821
816
  });
822
817
  await spinner("Adding hooks (BeforeTool, AfterTool, BeforeAgent, SessionStart, SessionEnd)", async () => {
823
- let hookCommand = GEMINI_HOOK_BINARY;
824
- try {
825
- const candidate = join(execNpm("prefix -g --no-workspaces", { timeout: 5e3 }).toString().trim(), "bin", GEMINI_HOOK_BINARY);
826
- if (existsSync(candidate)) hookCommand = candidate;
827
- } catch {
828
- }
829
- addGeminiHooks(settings, hookCommand);
818
+ addGeminiHooks(settings, quoteIfNeeded(resolveGlobalBinary(GEMINI_HOOK_BINARY) ?? GEMINI_HOOK_BINARY));
830
819
  });
831
820
  await spinner(`Writing ${GEMINI_SETTINGS}`, async () => {
832
821
  writeJson(GEMINI_SETTINGS, settings);
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ readLedgerSummary
4
+ } from "../chunk-R5AJNXZS.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-J7JWI3KU.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
@@ -21,9 +25,11 @@ Pushary Agent Hooks
21
25
  Commands:
22
26
  setup Configure Claude Code, Codex, Gemini CLI, Hermes, or Cursor with Pushary
23
27
  doctor Verify your Pushary installation is working
24
- clean Remove all Pushary configuration
28
+ clean Remove all Pushary configuration (--yes for non-interactive)
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:
@@ -2,6 +2,10 @@ import {
2
2
  denyReasonFrom,
3
3
  isDeferAnswer
4
4
  } from "./chunk-KQYIHZ5E.js";
5
+ import {
6
+ isGatingMoment,
7
+ recordKeylessMoment
8
+ } from "./chunk-R5AJNXZS.js";
5
9
  import {
6
10
  DEFAULT_SESSION,
7
11
  askUser,
@@ -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) {
@@ -60,7 +60,10 @@ var addPusharyToolPermissions = (settings) => {
60
60
  permissions.allow = filtered;
61
61
  };
62
62
  var addPusharyHooks = (settings, binDir) => {
63
- const resolve = (name) => binDir ? join(binDir, name) : name;
63
+ const resolve = (name) => {
64
+ const path = binDir ? join(binDir, name) : name;
65
+ return /\s/.test(path) ? `"${path}"` : path;
66
+ };
64
67
  const hooks = ensureRecord(settings, "hooks");
65
68
  const preToolUse = (Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []).filter((entry) => !isPusharyHook(entry));
66
69
  preToolUse.push({
@@ -0,0 +1,49 @@
1
+ // src/npm.ts
2
+ import { execSync } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import { join } from "path";
5
+ var cleanNpmEnv = () => {
6
+ const env = {};
7
+ for (const [key, value] of Object.entries(process.env)) {
8
+ if (key.toLowerCase().startsWith("npm_config_workspace")) continue;
9
+ env[key] = value;
10
+ }
11
+ return env;
12
+ };
13
+ var npmErrorMessage = (err) => {
14
+ const e = err;
15
+ const text = [e?.stderr, e?.stdout].map((part) => part ? part.toString() : "").join("\n");
16
+ 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));
17
+ return line || e?.message || String(err);
18
+ };
19
+ var execNpm = (args, options = {}) => {
20
+ return execSync(`npm ${args}`, {
21
+ timeout: 12e4,
22
+ stdio: "pipe",
23
+ ...options,
24
+ env: { ...cleanNpmEnv(), ...options.env ?? {} }
25
+ });
26
+ };
27
+ var resolveGlobalBinary = (name) => {
28
+ try {
29
+ const prefix = execNpm("prefix -g --no-workspaces", { timeout: 5e3 }).toString().trim();
30
+ const candidates = process.platform === "win32" ? [join(prefix, `${name}.cmd`), join(prefix, name)] : [join(prefix, "bin", name)];
31
+ return candidates.find(existsSync);
32
+ } catch {
33
+ return void 0;
34
+ }
35
+ };
36
+ var resolveGlobalBinDir = (probe) => {
37
+ if (process.platform === "win32") return void 0;
38
+ const resolved = resolveGlobalBinary(probe);
39
+ return resolved ? resolved.slice(0, resolved.length - probe.length - 1) : void 0;
40
+ };
41
+ var quoteIfNeeded = (path) => /\s/.test(path) ? `"${path}"` : path;
42
+
43
+ export {
44
+ npmErrorMessage,
45
+ execNpm,
46
+ resolveGlobalBinary,
47
+ resolveGlobalBinDir,
48
+ quoteIfNeeded
49
+ };
@@ -0,0 +1,64 @@
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
+ const sessions = /* @__PURE__ */ new Set();
34
+ for (const path of [`${ledgerPath()}.1`, ledgerPath()]) {
35
+ let raw;
36
+ try {
37
+ raw = readFileSync(path, "utf-8");
38
+ } catch {
39
+ continue;
40
+ }
41
+ for (const line of raw.split("\n")) {
42
+ if (!line.trim()) continue;
43
+ let entry;
44
+ try {
45
+ entry = JSON.parse(line);
46
+ } catch {
47
+ continue;
48
+ }
49
+ if (typeof entry.ts !== "number" || entry.ts < sinceMs) continue;
50
+ summary.count += 1;
51
+ const tool = typeof entry.tool === "string" && entry.tool ? entry.tool : "Other";
52
+ summary.byTool[tool] = (summary.byTool[tool] ?? 0) + 1;
53
+ sessions.add(entry.sessionId || `${entry.project ?? "unknown"}:no-session`);
54
+ }
55
+ }
56
+ summary.sessions = sessions.size;
57
+ return summary;
58
+ };
59
+
60
+ export {
61
+ isGatingMoment,
62
+ recordKeylessMoment,
63
+ readLedgerSummary
64
+ };
@@ -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,7 +1,8 @@
1
1
  import {
2
2
  handlePreToolUse
3
- } from "../chunk-P6QBZS27.js";
3
+ } from "../chunk-CY5YP34L.js";
4
4
  import "../chunk-KQYIHZ5E.js";
5
+ import "../chunk-R5AJNXZS.js";
5
6
  import {
6
7
  askUser,
7
8
  cancelQuestion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushary/agent-hooks",
3
- "version": "0.28.3",
3
+ "version": "0.30.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",