@pushary/agent-hooks 0.60.0 → 0.62.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +52 -9
  3. package/dist/bin/pushary-claude.js +6 -6
  4. package/dist/bin/pushary-clean.js +83 -38
  5. package/dist/bin/pushary-codex-hook.js +6 -6
  6. package/dist/bin/pushary-codex.js +4 -4
  7. package/dist/bin/pushary-connect.d.ts +1 -0
  8. package/dist/bin/pushary-connect.js +92 -0
  9. package/dist/bin/pushary-daemon.js +42 -5
  10. package/dist/bin/pushary-doctor.js +246 -71
  11. package/dist/bin/pushary-gemini-hook.js +6 -6
  12. package/dist/bin/pushary-hook.js +10 -5
  13. package/dist/bin/pushary-login.d.ts +1 -0
  14. package/dist/bin/pushary-login.js +156 -0
  15. package/dist/bin/pushary-logout.d.ts +1 -0
  16. package/dist/bin/pushary-logout.js +144 -0
  17. package/dist/bin/pushary-mode.js +24 -13
  18. package/dist/bin/pushary-notification-hook.js +4 -4
  19. package/dist/bin/pushary-permission-denied-hook.js +5 -5
  20. package/dist/bin/pushary-permission-hook.js +5 -5
  21. package/dist/bin/pushary-post-hook.js +4 -4
  22. package/dist/bin/pushary-prompt-hook.js +4 -4
  23. package/dist/bin/pushary-session-end-hook.js +4 -4
  24. package/dist/bin/pushary-session-start-hook.js +4 -4
  25. package/dist/bin/pushary-setup.js +769 -518
  26. package/dist/bin/pushary-stats.js +6 -1
  27. package/dist/bin/pushary-status.d.ts +1 -0
  28. package/dist/bin/pushary-status.js +206 -0
  29. package/dist/bin/pushary-stop-hook.js +4 -4
  30. package/dist/bin/pushary-stopfailure-hook.js +4 -4
  31. package/dist/bin/pushary-suggestions.js +10 -5
  32. package/dist/bin/pushary-upgrade.js +184 -14
  33. package/dist/bin/pushary-wait.js +21 -12
  34. package/dist/bin/pushary.js +42 -60
  35. package/dist/chunk-247C4RE5.js +236 -0
  36. package/dist/{chunk-NKXSILEW.js → chunk-2UMNXADU.js} +18 -2
  37. package/dist/chunk-3EGEA4KH.js +44 -0
  38. package/dist/chunk-3O27ZSRE.js +569 -0
  39. package/dist/{chunk-BE5X3WXL.js → chunk-5SO3CEGJ.js} +5 -5
  40. package/dist/chunk-7QLSKOSU.js +19 -0
  41. package/dist/chunk-E2U35RLD.js +108 -0
  42. package/dist/chunk-ER657KRJ.js +168 -0
  43. package/dist/{chunk-WNRYRN2R.js → chunk-HIIBSYFJ.js} +5 -5
  44. package/dist/{chunk-DWED7BS3.js → chunk-HNTKTK5B.js} +1 -1
  45. package/dist/chunk-IIZZYUWK.js +18 -0
  46. package/dist/{chunk-J7JWI3KU.js → chunk-MUEW424A.js} +19 -1
  47. package/dist/chunk-N4DA4CJB.js +57 -0
  48. package/dist/chunk-N7XJ4L2W.js +79 -0
  49. package/dist/chunk-ONVEYEVR.js +44 -0
  50. package/dist/chunk-QHOVJXOT.js +255 -0
  51. package/dist/chunk-SP7OZXCQ.js +224 -0
  52. package/dist/chunk-TX7KBKT7.js +79 -0
  53. package/dist/chunk-UXXRRXUS.js +93 -0
  54. package/dist/chunk-YJ7YZRVV.js +217 -0
  55. package/dist/src/index.js +5 -5
  56. package/package.json +11 -4
  57. package/dist/chunk-BQLCELYC.js +0 -332
@@ -1,317 +1,174 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- installClaudeAlias,
4
- resolveShellRc
5
- } from "../chunk-BC3VCZ3E.js";
3
+ runBrowserLogin
4
+ } from "../chunk-QHOVJXOT.js";
5
+ import {
6
+ addClaudeMcpServer,
7
+ addPusharyHooks,
8
+ addPusharyToolPermissions
9
+ } from "../chunk-CAJZAFVS.js";
10
+ import {
11
+ describeDetection,
12
+ detectAllAgents,
13
+ isDetected
14
+ } from "../chunk-UXXRRXUS.js";
15
+ import {
16
+ renderAgentInstructions,
17
+ renderProjectAgentInstructions,
18
+ writeInstructionBlock
19
+ } from "../chunk-N7XJ4L2W.js";
6
20
  import {
7
21
  GEMINI_HOOK_BINARY,
22
+ addGeminiHooks,
23
+ addGeminiMcpServer
24
+ } from "../chunk-E2U35RLD.js";
25
+ import {
8
26
  addCodexHookTrust,
9
27
  addCodexHooks,
10
28
  addCodexMcpServer,
11
- addGeminiHooks,
12
- addGeminiMcpServer,
13
- renderAgentInstructions,
14
- renderProjectAgentInstructions,
15
- writeInstructionBlock
16
- } from "../chunk-BQLCELYC.js";
29
+ codexConfigToml,
30
+ codexHome
31
+ } from "../chunk-ER657KRJ.js";
32
+ import {
33
+ confirmAppConnection,
34
+ connectDevice,
35
+ connectViaAppPairing,
36
+ printConnectInstructions,
37
+ setHumanStream
38
+ } from "../chunk-3O27ZSRE.js";
39
+ import "../chunk-3EGEA4KH.js";
40
+ import {
41
+ parseFlags
42
+ } from "../chunk-N4DA4CJB.js";
43
+ import {
44
+ KEY_FILE_MODE,
45
+ backupFile,
46
+ exportLine,
47
+ readJsonSafe,
48
+ writeFileAtomic,
49
+ writeJsonAtomic,
50
+ writeKey
51
+ } from "../chunk-247C4RE5.js";
17
52
  import {
18
- execNpm,
53
+ checkApiKey,
54
+ describeKeyCheck
55
+ } from "../chunk-YJ7YZRVV.js";
56
+ import {
57
+ UsageError
58
+ } from "../chunk-7QLSKOSU.js";
59
+ import {
60
+ installClaudeAlias,
61
+ resolveShellRc
62
+ } from "../chunk-BC3VCZ3E.js";
63
+ import {
64
+ installGlobally,
19
65
  npmErrorMessage,
20
66
  quoteIfNeeded,
21
67
  resolveGlobalBinDir,
22
68
  resolveGlobalBinary
23
- } from "../chunk-J7JWI3KU.js";
69
+ } from "../chunk-MUEW424A.js";
24
70
  import {
25
- addClaudeMcpServer,
26
- addPusharyHooks,
27
- addPusharyToolPermissions
28
- } from "../chunk-CAJZAFVS.js";
71
+ EXIT,
72
+ getPackageVersion,
73
+ renderCommandHelp
74
+ } from "../chunk-SP7OZXCQ.js";
29
75
  import {
30
76
  reportEvent
31
- } from "../chunk-WNRYRN2R.js";
32
- import "../chunk-RN3NOEJF.js";
33
- import "../chunk-DWED7BS3.js";
77
+ } from "../chunk-HIIBSYFJ.js";
78
+ import "../chunk-HNTKTK5B.js";
34
79
  import {
35
80
  isValidApiKey
36
81
  } from "../chunk-DQAN3JQP.js";
37
- import "../chunk-NKXSILEW.js";
82
+ import "../chunk-RN3NOEJF.js";
83
+ import "../chunk-2UMNXADU.js";
38
84
 
39
85
  // bin/pushary-setup.ts
40
- import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, cpSync, rmSync, chmodSync } from "fs";
86
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync, renameSync } from "fs";
41
87
  import { join, dirname, basename } from "path";
42
- import { homedir } from "os";
88
+ import { homedir, tmpdir } from "os";
43
89
  import { execSync as execSync2 } from "child_process";
44
90
  import { checkbox, input, confirm } from "@inquirer/prompts";
45
91
  import { fileURLToPath } from "url";
46
92
  import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
47
93
 
48
- // src/onboarding.ts
49
- import qrcodeTerminal from "qrcode-terminal";
50
-
51
- // src/pairing.ts
52
- import crypto from "crypto";
53
- var X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
54
- var HKDF_INFO = Buffer.from("pushary-pair-v1");
55
- var HEADER_BYTES = 32 + 12 + 16;
56
- var rawToPublicKey = (raw) => crypto.createPublicKey({
57
- key: Buffer.concat([X25519_SPKI_PREFIX, raw]),
58
- format: "der",
59
- type: "spki"
60
- });
61
- var publicKeyToRaw = (key) => key.export({ format: "der", type: "spki" }).subarray(-32);
62
- var generateKeypair = () => {
63
- const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
64
- const publicKeyRaw = publicKeyToRaw(publicKey);
65
- return { publicKeyB64: publicKeyRaw.toString("base64"), publicKeyRaw, privateKey };
66
- };
67
- var publicKeyFingerprint = (b64) => {
68
- const raw = Buffer.from(b64, "base64");
69
- const digest = crypto.createHash("sha256").update(raw).digest("hex");
70
- return (digest.slice(0, 8).match(/.{2}/g) ?? []).join(" ").toUpperCase();
71
- };
72
- var openSealedKey = (blobB64, keypair) => {
73
- const blob = Buffer.from(blobB64, "base64");
74
- if (blob.length <= HEADER_BYTES) throw new Error("sealed payload too short");
75
- const ephemeralRaw = blob.subarray(0, 32);
76
- const nonce = blob.subarray(32, 44);
77
- const tag = blob.subarray(44, 60);
78
- const ciphertext = blob.subarray(60);
79
- const shared = crypto.diffieHellman({
80
- privateKey: keypair.privateKey,
81
- publicKey: rawToPublicKey(ephemeralRaw)
82
- });
83
- const salt = Buffer.concat([ephemeralRaw, keypair.publicKeyRaw]);
84
- const key = Buffer.from(crypto.hkdfSync("sha256", shared, salt, HKDF_INFO, 32));
85
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
86
- decipher.setAuthTag(tag);
87
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
88
- };
89
-
90
- // src/onboarding.ts
91
- var dim = (s) => `\x1B[2m${s}\x1B[0m`;
92
- var bold = (s) => `\x1B[1m${s}\x1B[0m`;
93
- var green = (s) => `\x1B[32m${s}\x1B[0m`;
94
- var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
95
- var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
96
- var check = green("\u2713");
97
- var API_BASE = process.env.PUSHARY_API_URL?.trim() || "https://pushary.com";
98
- var CONNECT_POLL_INTERVAL_MS = 2500;
99
- var CONNECT_TIMEOUT_MS = 18e4;
100
- var DELIVERY_POLL_INTERVAL_MS = 1500;
101
- var DELIVERY_TIMEOUT_MS = 2e4;
102
- var authHeaders = (apiKey) => ({
103
- "Content-Type": "application/json",
104
- Authorization: `Bearer ${apiKey}`
105
- });
106
- var getJson = async (url, apiKey) => {
107
- try {
108
- const res = await fetch(url, { headers: authHeaders(apiKey), signal: AbortSignal.timeout(1e4) });
109
- if (!res.ok) return null;
110
- return await res.json().catch(() => null);
111
- } catch {
112
- return null;
94
+ // src/setup/closing.ts
95
+ var ACTIVITY_URL = "pushary.com/dashboard/agent/activity";
96
+ var COMMAND_SURFACE = [
97
+ { command: "pushary status", summary: "what this machine is connected to" },
98
+ { command: "pushary mode", summary: "switch between ask, auto and notify-only" },
99
+ { command: "pushary wait", summary: "block until a pending question is answered" },
100
+ { command: "pushary stats", summary: "what your agents have been asking" },
101
+ { command: "pushary claude", summary: "run Claude Code with the Pushary wrapper" },
102
+ { command: "pushary doctor", summary: "check the install" }
103
+ ];
104
+ var SURFACE_COLUMN = Math.max(...COMMAND_SURFACE.map((entry) => entry.command.length)) + 2;
105
+ var renderCommandSurface = () => COMMAND_SURFACE.map((entry) => `${entry.command.padEnd(SURFACE_COLUMN)}${entry.summary}`);
106
+ var nextSteps = (facts, reachable) => {
107
+ const lines = [];
108
+ if (facts.completed.includes("claude_code")) {
109
+ lines.push("pushary claude run Claude Code so your phone can start and steer it");
113
110
  }
114
- };
115
- var fetchSite = async (apiKey) => {
116
- const data = await getJson(`${API_BASE}/api/v1/server/site`, apiKey);
117
- if (!data || typeof data.slug !== "string" || typeof data.subscribeUrl !== "string") return null;
118
- return { slug: data.slug, name: typeof data.name === "string" ? data.name : data.slug, subscribeUrl: data.subscribeUrl };
119
- };
120
- var activeSubscriberCount = async (apiKey) => {
121
- const data = await getJson(`${API_BASE}/api/v1/server/subscribers/count`, apiKey);
122
- return data && typeof data.active === "number" ? data.active : 0;
123
- };
124
- var sendTestNotification = async (apiKey) => {
125
- const res = await fetch(`${API_BASE}/api/v1/server/send`, {
126
- method: "POST",
127
- headers: authHeaders(apiKey),
128
- body: JSON.stringify({
129
- title: "Pushary is connected",
130
- body: "Your AI agent can now reach you right here.",
131
- metadata: { source: "cli-onboarding" }
132
- }),
133
- signal: AbortSignal.timeout(15e3)
134
- });
135
- const data = await res.json().catch(() => ({}));
136
- if (!res.ok) throw new Error(typeof data.error === "string" ? data.error : res.statusText);
137
- return Array.isArray(data.notificationIds) ? data.notificationIds : [];
138
- };
139
- var isDelivered = async (apiKey, id) => {
140
- const data = await getJson(`${API_BASE}/api/v1/server/notifications/${id}`, apiKey);
141
- return !!data && (data.status === "delivered" || data.deliveredAt != null);
142
- };
143
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
144
- var startSpinner = (getLabel) => {
145
- const frames = [" ", ". ", ".. ", "..."];
146
- let i = 0;
147
- const interval = setInterval(() => {
148
- process.stdout.write(`\r ${dim(frames[i++ % frames.length])} ${getLabel()}`);
149
- }, 200);
150
- return (finalGlyph, finalLabel) => {
151
- clearInterval(interval);
152
- process.stdout.write(`\r ${finalGlyph} ${finalLabel}\x1B[K
153
- `);
154
- };
155
- };
156
- var printQr = (url) => new Promise((resolve, reject) => {
157
- try {
158
- qrcodeTerminal.generate(url, { small: true }, (qr) => {
159
- process.stdout.write("\n" + qr.split("\n").map((line) => " " + line).join("\n") + "\n");
160
- resolve();
161
- });
162
- } catch (err) {
163
- reject(err);
164
- }
165
- });
166
- var waitForDevice = async (apiKey) => {
167
- const deadline = Date.now() + CONNECT_TIMEOUT_MS;
168
- const stop = startSpinner(() => {
169
- const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
170
- return `Waiting for your phone ${dim(`(${left}s)`)}`;
171
- });
172
- try {
173
- while (Date.now() < deadline) {
174
- if (await activeSubscriberCount(apiKey) > 0) {
175
- stop(check, "Phone connected");
176
- return true;
177
- }
178
- await sleep(CONNECT_POLL_INTERVAL_MS);
179
- }
180
- stop(yellow("!"), "Didn't detect a connection yet");
181
- return false;
182
- } catch (err) {
183
- stop(yellow("!"), `Couldn't check connection ${dim(`(${err instanceof Error ? err.message : "network error"})`)}`);
184
- return false;
185
- }
186
- };
187
- var sendAndConfirm = async (apiKey) => {
188
- let notificationIds = [];
189
- const stopSend = startSpinner(() => "Sending a test notification");
190
- try {
191
- notificationIds = await sendTestNotification(apiKey);
192
- stopSend(check, "Test notification sent");
193
- } catch (err) {
194
- stopSend(yellow("!"), `Couldn't send test notification ${dim(`(${err instanceof Error ? err.message : "error"})`)}`);
195
- return;
196
- }
197
- if (notificationIds.length === 0) return;
198
- const deadline = Date.now() + DELIVERY_TIMEOUT_MS;
199
- const stop = startSpinner(() => "Confirming it reached your phone");
200
- while (Date.now() < deadline) {
201
- const results = await Promise.all(notificationIds.map((id) => isDelivered(apiKey, id)));
202
- if (results.some(Boolean)) {
203
- stop(check, "Received on your phone");
204
- return;
205
- }
206
- await sleep(DELIVERY_POLL_INTERVAL_MS);
111
+ if (reachable) {
112
+ lines.push("pushary daemon keep this machine reachable with nothing running");
113
+ } else {
114
+ lines.push("pushary connect connect a phone without re-running setup");
207
115
  }
208
- stop(yellow("!"), "Sent \u2014 couldn't confirm receipt; check your phone");
116
+ lines.push("pushary doctor re-check everything");
117
+ return lines;
209
118
  };
210
- var connectDevice = async (apiKey) => {
211
- const site = await fetchSite(apiKey);
212
- if (!site) {
213
- console.log(` ${yellow("!")} Couldn't load your subscribe link. Connect your phone later at ${cyan("pushary.com")}.`);
214
- return false;
119
+ var describeClosing = (facts) => {
120
+ const reachable = facts.reachChecked && facts.reachesYou;
121
+ if (facts.configuredNothing) {
122
+ return {
123
+ ok: false,
124
+ headline: "Setup failed.",
125
+ next: ["Nothing was configured. Run pushary doctor to see why."],
126
+ exitCode: EXIT.FAILED
127
+ };
215
128
  }
216
- if (await activeSubscriberCount(apiKey) > 0) {
217
- console.log(` ${check} Phone already connected`);
218
- await sendAndConfirm(apiKey);
219
- return true;
129
+ if (facts.failed.length > 0) {
130
+ const count = facts.failed.length;
131
+ return {
132
+ ok: false,
133
+ headline: `Setup finished with ${count} problem${count === 1 ? "" : "s"}.`,
134
+ next: [
135
+ ...nextSteps(facts, reachable),
136
+ "",
137
+ `Approvals: ${ACTIVITY_URL}`
138
+ ],
139
+ exitCode: EXIT.PROBLEMS_FOUND
140
+ };
220
141
  }
221
- console.log();
222
- console.log(` ${bold("Connect your phone")}`);
223
- console.log(` ${dim("Scan this with your phone camera to get push notifications:")}`);
224
- await printQr(site.subscribeUrl);
225
- console.log(` ${dim("or open")} ${cyan(site.subscribeUrl)}`);
226
- console.log();
227
- const connected = await waitForDevice(apiKey);
228
- if (!connected) {
229
- console.log(` ${dim("No rush \u2014 open")} ${cyan(site.subscribeUrl)} ${dim("on your phone whenever you're ready.")}`);
230
- return false;
142
+ if (facts.reachChecked && !facts.reachesYou) {
143
+ return {
144
+ ok: false,
145
+ headline: "Agents configured. Your phone is not connected yet.",
146
+ next: [
147
+ "Approvals fall back to the terminal until a device of yours can answer.",
148
+ "",
149
+ ...nextSteps(facts, false)
150
+ ],
151
+ exitCode: EXIT.NO_DEVICE
152
+ };
231
153
  }
232
- await sendAndConfirm(apiKey);
233
- return true;
234
- };
235
- var printConnectInstructions = async (apiKey) => {
236
- const site = await fetchSite(apiKey);
237
- const target = site?.subscribeUrl ?? "https://pushary.com";
238
- console.log(` ${dim("Connect your phone for push notifications at")} ${cyan(target)}`);
239
- };
240
- var PAIR_POLL_INTERVAL_MS = 2e3;
241
- var PAIR_TIMEOUT_MS = 18e4;
242
- var startPairing = async (cliPublicKey) => {
243
- try {
244
- const res = await fetch(`${API_BASE}/api/mobile/pair/start`, {
245
- method: "POST",
246
- headers: { "Content-Type": "application/json" },
247
- body: JSON.stringify({ cliPublicKey }),
248
- signal: AbortSignal.timeout(1e4)
249
- });
250
- if (!res.ok) return null;
251
- const data = await res.json().catch(() => null);
252
- return data && typeof data.pairId === "string" ? data.pairId : null;
253
- } catch {
254
- return null;
255
- }
256
- };
257
- var claimPairing = async (pairId) => {
258
- try {
259
- const res = await fetch(`${API_BASE}/api/mobile/pair/claim?id=${encodeURIComponent(pairId)}`, {
260
- signal: AbortSignal.timeout(1e4)
261
- });
262
- if (!res.ok) return null;
263
- return await res.json().catch(() => null);
264
- } catch {
265
- return null;
266
- }
267
- };
268
- var connectViaAppPairing = async () => {
269
- const keypair = generateKeypair();
270
- const pairId = await startPairing(keypair.publicKeyB64);
271
- if (!pairId) {
272
- console.log(` ${yellow("!")} Couldn't start pairing. Check your connection and try again.`);
273
- return null;
274
- }
275
- const deepLink = `pushary://pair?pk=${encodeURIComponent(keypair.publicKeyB64)}&id=${encodeURIComponent(pairId)}`;
276
- console.log();
277
- console.log(` ${bold("Connect your Pushary app")}`);
278
- console.log(` ${dim("Open the Pushary app, tap Connect agent, and scan this:")}`);
279
- await printQr(deepLink);
280
- console.log(` ${dim("No camera handy? Open this link on the phone instead:")}`);
281
- console.log(` ${cyan(deepLink)}`);
282
- console.log(` ${dim("Confirm the app shows fingerprint")} ${cyan(publicKeyFingerprint(keypair.publicKeyB64))}`);
283
- console.log();
284
- const deadline = Date.now() + PAIR_TIMEOUT_MS;
285
- const stop = startSpinner(() => {
286
- const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
287
- return `Waiting for the app to authorize ${dim(`(${left}s)`)}`;
288
- });
289
- try {
290
- while (Date.now() < deadline) {
291
- const claim = await claimPairing(pairId);
292
- if (claim?.status === "authorized") {
293
- let apiKey;
294
- try {
295
- apiKey = openSealedKey(claim.sealed, keypair);
296
- } catch {
297
- stop(yellow("!"), "Pairing failed to decrypt \u2014 re-run setup to try again");
298
- return null;
299
- }
300
- stop(check, "App connected");
301
- return { apiKey, handle: claim.siteSlug };
302
- }
303
- if (claim?.status === "expired") {
304
- stop(yellow("!"), "Pairing expired \u2014 re-run setup to try again");
305
- return null;
306
- }
307
- await sleep(PAIR_POLL_INTERVAL_MS);
308
- }
309
- stop(yellow("!"), "Timed out waiting for the app");
310
- return null;
311
- } catch (err) {
312
- stop(yellow("!"), `Pairing error ${dim(`(${err instanceof Error ? err.message : "network"})`)}`);
313
- return null;
154
+ if (!facts.reachChecked) {
155
+ return {
156
+ ok: true,
157
+ headline: "Setup complete.",
158
+ next: [
159
+ "The phone check was skipped, so nothing here proves an approval reaches you.",
160
+ "",
161
+ ...nextSteps(facts, false)
162
+ ],
163
+ exitCode: EXIT.OK
164
+ };
314
165
  }
166
+ return {
167
+ ok: true,
168
+ headline: "Setup complete.",
169
+ next: [...nextSteps(facts, true), "", `Approvals: ${ACTIVITY_URL}`],
170
+ exitCode: EXIT.OK
171
+ };
315
172
  };
316
173
 
317
174
  // src/skills-cli.ts
@@ -328,45 +185,115 @@ var installSkillViaSkillsCli = (agent) => {
328
185
  }
329
186
  };
330
187
 
188
+ // src/setup/options.ts
189
+ var AGENT_CHOICES = ["claude_code", "codex", "gemini_cli", "hermes", "cursor", "custom"];
190
+ var SETUP_FLAGS = {
191
+ bools: ["key-stdin", "skip-phone", "yes", "y", "json", "dry-run", "help", "h"],
192
+ values: ["key", "connect", "agents"]
193
+ };
194
+ var isAgentChoice = (value) => AGENT_CHOICES.includes(value);
195
+ var parseAgentSelection = (raw, yes) => {
196
+ if (raw === void 0) {
197
+ return yes ? { kind: "auto" } : { kind: "ask" };
198
+ }
199
+ const value = raw.trim().toLowerCase();
200
+ if (value === "auto") return { kind: "auto" };
201
+ if (value === "none") return { kind: "none" };
202
+ const parts = value.split(",").map((part) => part.trim()).filter((part) => part !== "");
203
+ if (parts.length === 0) throw new UsageError("--agents needs a value");
204
+ const unknown = parts.filter((part) => !isAgentChoice(part));
205
+ if (unknown.length > 0) {
206
+ throw new UsageError(
207
+ `--agents does not know: ${unknown.join(", ")}. Use auto, none, or any of: ${AGENT_CHOICES.join(", ")}`
208
+ );
209
+ }
210
+ return { kind: "explicit", agents: [...new Set(parts)] };
211
+ };
212
+ var parseSetupOptions = (input2) => {
213
+ const flags = parseFlags(input2.argv, SETUP_FLAGS);
214
+ const stray = [
215
+ ...flags.command !== void 0 && flags.command !== "setup" ? [flags.command] : [],
216
+ ...flags.rest
217
+ ];
218
+ if (stray.length > 0) {
219
+ throw new UsageError(
220
+ `setup takes no positional arguments (got ${stray.join(", ")}). Did you mean --agents ${stray[0]}?`
221
+ );
222
+ }
223
+ const yes = flags.bools.has("yes") || flags.bools.has("y");
224
+ const help = flags.bools.has("help") || flags.bools.has("h");
225
+ const agentsFlag = flags.values.get("agents");
226
+ const agents = parseAgentSelection(agentsFlag, yes);
227
+ const connectRaw = flags.values.get("connect");
228
+ if (connectRaw !== void 0 && connectRaw !== "web" && connectRaw !== "app") {
229
+ throw new UsageError(`--connect must be one of: web, app (got ${connectRaw})`);
230
+ }
231
+ const key = flags.values.get("key")?.trim() || null;
232
+ const keyStdin = flags.bools.has("key-stdin");
233
+ if (key && keyStdin) {
234
+ throw new UsageError("Pass either --key or --key-stdin, not both");
235
+ }
236
+ return {
237
+ connectMode: connectRaw === "app" ? "app" : "web",
238
+ connectExplicit: connectRaw !== void 0,
239
+ skipPhone: flags.bools.has("skip-phone"),
240
+ // Anything that means "there is nobody to answer a prompt". A non-TTY stdin
241
+ // is included because that is the case that used to hang: it is better to
242
+ // take the defaults and say so than to wait forever for a keypress.
243
+ nonInteractive: yes || agentsFlag !== void 0 || !input2.stdinIsTty,
244
+ json: flags.bools.has("json"),
245
+ dryRun: flags.bools.has("dry-run"),
246
+ help,
247
+ agents,
248
+ key,
249
+ keyStdin
250
+ };
251
+ };
252
+
331
253
  // bin/pushary-setup.ts
332
254
  var CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
333
255
  var CLAUDE_JSON = join(homedir(), ".claude.json");
334
256
  var CURSOR_PLUGIN_DIR = join(homedir(), ".cursor", "plugins", "local", "pushary");
335
257
  var CURSOR_USER_HOOKS = join(homedir(), ".cursor", "hooks.json");
336
258
  var CLAUDE_SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
337
- var CODEX_HOME = process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
259
+ var CODEX_HOME = codexHome();
338
260
  var CODEX_SKILL_DIR = join(CODEX_HOME, "skills", "pushary");
339
261
  var CODEX_AGENTS_MD = join(CODEX_HOME, "AGENTS.md");
340
262
  var GEMINI_SETTINGS = join(homedir(), ".gemini", "settings.json");
341
263
  var GEMINI_MD = join(homedir(), ".gemini", "GEMINI.md");
342
- var PUSHARY_CONFIG_DIR = join(homedir(), ".pushary");
343
- var PUSHARY_CONFIG_FILE = join(PUSHARY_CONFIG_DIR, "config.json");
344
- var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
345
- var dim2 = (s) => `\x1B[2m${s}\x1B[0m`;
346
- var bold2 = (s) => `\x1B[1m${s}\x1B[0m`;
347
- var green2 = (s) => `\x1B[32m${s}\x1B[0m`;
348
- var cyan2 = (s) => `\x1B[36m${s}\x1B[0m`;
349
- var yellow2 = (s) => `\x1B[33m${s}\x1B[0m`;
350
- var check2 = green2("\u2713");
351
- var parseKeyFlag = () => {
352
- const args = process.argv.slice(2);
353
- for (let i = 0; i < args.length; i++) {
354
- if (args[i] === "--key" && args[i + 1]) return args[i + 1].trim();
355
- if (args[i].startsWith("--key=")) return args[i].slice(6).trim();
356
- }
357
- return void 0;
264
+ var PUSHARY_CONFIG_FILE = join(homedir(), ".pushary", "config.json");
265
+ var manualSteps = [];
266
+ var noteManual = (step) => {
267
+ manualSteps.push(step);
358
268
  };
359
- var parseConnectFlag = () => {
360
- const args = process.argv.slice(2);
361
- for (let i = 0; i < args.length; i++) {
362
- if (args[i] === "--connect" && args[i + 1]) {
363
- return args[i + 1].trim() === "app" ? "app" : "web";
364
- }
365
- if (args[i].startsWith("--connect=")) {
366
- return args[i].slice(10).trim() === "app" ? "app" : "web";
367
- }
269
+ var humanStream = process.stdout;
270
+ var writeHuman = (text) => {
271
+ humanStream.write(text);
272
+ };
273
+ var dim = (s) => `\x1B[2m${s}\x1B[0m`;
274
+ var bold = (s) => `\x1B[1m${s}\x1B[0m`;
275
+ var green = (s) => `\x1B[32m${s}\x1B[0m`;
276
+ var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
277
+ var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
278
+ var check = green("\u2713");
279
+ var readKeyFromStdin = async (options) => {
280
+ if (!options.keyStdin) return void 0;
281
+ const chunks = [];
282
+ for await (const chunk of process.stdin) chunks.push(chunk);
283
+ const first = Buffer.concat(chunks).toString("utf-8").split("\n", 1)[0]?.trim();
284
+ if (!first) {
285
+ console.log(`
286
+ ${yellow("!")} --key-stdin was passed but stdin was empty.`);
287
+ process.exit(2);
288
+ }
289
+ if (!isValidApiKey(first)) {
290
+ console.log(`
291
+ ${yellow("!")} The key on stdin is not a valid Pushary key. Expected: ${dim("pk_xxx.xxx")}`);
292
+ console.log(` ${dim("Copy your key from")} ${cyan("https://pushary.com/dashboard/agent/settings")}
293
+ `);
294
+ process.exit(2);
368
295
  }
369
- return "web";
296
+ return first;
370
297
  };
371
298
  var isInstalled = (command) => {
372
299
  const whichCmd = process.platform === "win32" ? "where" : "which";
@@ -384,11 +311,40 @@ var readJson = (filePath) => {
384
311
  return {};
385
312
  }
386
313
  };
314
+ var readAgentJson = (filePath) => {
315
+ const result = readJsonSafe(filePath);
316
+ if (result.kind === "ok") return result.value;
317
+ if (result.kind === "missing") return {};
318
+ const backup = backupFile(filePath);
319
+ const reason = result.kind === "unparseable" ? "is not valid JSON" : "could not be read";
320
+ throw new Error(
321
+ `${filePath} ${reason}, so it was left untouched. A copy is at ${backup}. Fix or remove the original, then re-run setup.`
322
+ );
323
+ };
387
324
  var writeJson = (filePath, data) => {
388
325
  const dir = dirname(filePath);
389
326
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
390
327
  writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
391
328
  };
329
+ var readAgentToml = (filePath) => {
330
+ let raw;
331
+ try {
332
+ raw = readFileSync(filePath, "utf-8");
333
+ } catch (err) {
334
+ if (err.code === "ENOENT") return {};
335
+ throw new Error(`${filePath} could not be read, so it was left untouched.`);
336
+ }
337
+ if (raw.trim() === "") return {};
338
+ try {
339
+ return parseTOML(raw);
340
+ } catch {
341
+ const backup = backupFile(filePath);
342
+ throw new Error(
343
+ `${filePath} is not valid TOML, so it was left untouched. A copy is at ${backup}. Fix or remove the original, then re-run setup.`
344
+ );
345
+ }
346
+ };
347
+ var isCancellation = (err) => err instanceof Error && (err.name === "ExitPromptError" || /force closed the prompt/i.test(err.message));
392
348
  var formatError = (err) => {
393
349
  if (err instanceof Error) {
394
350
  const errWithSignal = err;
@@ -403,29 +359,20 @@ var spinner = async (label, fn, options = {}) => {
403
359
  const frames = [" ", ". ", ".. ", "..."];
404
360
  let i = 0;
405
361
  const interval = setInterval(() => {
406
- process.stdout.write(`\r ${dim2(frames[i++ % frames.length])} ${label}`);
362
+ writeHuman(`\r ${dim(frames[i++ % frames.length])} ${label}`);
407
363
  }, 200);
408
364
  try {
409
365
  await fn();
410
366
  clearInterval(interval);
411
- process.stdout.write(`\r ${check2} ${label}
367
+ writeHuman(`\r ${check} ${label}
412
368
  `);
413
369
  } catch (err) {
414
370
  clearInterval(interval);
415
- process.stdout.write(`\r ${yellow2("!")} ${label} ${dim2(`(${formatError(err)})`)}
371
+ writeHuman(`\r ${yellow("!")} ${label} ${dim(`(${formatError(err)})`)}
416
372
  `);
417
373
  if (!options.optional) throw err;
418
374
  }
419
375
  };
420
- var getPackageVersion = () => {
421
- try {
422
- const __dirname = dirname(fileURLToPath(import.meta.url));
423
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
424
- return pkg.version ?? "0.0.0";
425
- } catch {
426
- return "0.0.0";
427
- }
428
- };
429
376
  var checkForUpdates = async (current) => {
430
377
  try {
431
378
  const res = await fetch("https://registry.npmjs.org/@pushary/agent-hooks/latest", {
@@ -434,17 +381,18 @@ var checkForUpdates = async (current) => {
434
381
  const data = await res.json();
435
382
  const latest = data.version;
436
383
  if (latest && latest !== current) {
437
- console.log(` ${yellow2("!")} Update available: ${dim2(current)} \u2192 ${green2(latest)}`);
438
- console.log(` ${dim2("Run:")} npx @pushary/agent-hooks@latest upgrade`);
384
+ console.log(` ${yellow("!")} Update available: ${dim(current)} \u2192 ${green(latest)}`);
385
+ console.log(` ${dim("Run:")} npx @pushary/agent-hooks@latest upgrade`);
439
386
  console.log();
440
387
  }
441
388
  } catch {
442
389
  }
443
390
  };
444
- var installGlobally = async () => {
445
- await spinner("Installing pushary-hook globally", async () => {
391
+ var installGlobally2 = async () => {
392
+ const version = getPackageVersion();
393
+ await spinner(`Installing pushary-hook globally (${version})`, async () => {
446
394
  try {
447
- execNpm("install -g --no-workspaces @pushary/agent-hooks@latest", { timeout: 12e4 });
395
+ await installGlobally({ version });
448
396
  } catch (err) {
449
397
  throw new Error(npmErrorMessage(err));
450
398
  }
@@ -474,36 +422,40 @@ var installSkill = async (agent, fallbackDir) => {
474
422
  };
475
423
  var setupClaudeCode = async (apiKey) => {
476
424
  console.log(`
477
- ${bold2("Setting up Claude Code")}
425
+ ${bold("Setting up Claude Code")}
478
426
  `);
479
- const settings = readJson(CLAUDE_SETTINGS);
427
+ await installGlobally2();
428
+ const claudeJson = readAgentJson(CLAUDE_JSON);
429
+ const settings = readAgentJson(CLAUDE_SETTINGS);
480
430
  await spinner("Adding MCP server (type: http)", async () => {
481
- const data = readJson(CLAUDE_JSON);
482
- addClaudeMcpServer(data, apiKey);
483
- writeJson(CLAUDE_JSON, data);
431
+ addClaudeMcpServer(claudeJson, apiKey);
484
432
  });
485
433
  await spinner("Auto-allowing Pushary tools", async () => {
486
434
  addPusharyToolPermissions(settings);
487
435
  });
488
- await installGlobally();
489
436
  await spinner("Adding hooks (approvals, activity, presence, and lifecycle events)", async () => {
490
437
  addPusharyHooks(settings, resolveGlobalBinDir("pushary-hook"));
491
438
  });
439
+ await spinner(`Writing ${CLAUDE_JSON}`, async () => {
440
+ writeJsonAtomic(CLAUDE_JSON, claudeJson, 384);
441
+ });
492
442
  await spinner(`Writing ${CLAUDE_SETTINGS}`, async () => {
493
- writeJson(CLAUDE_SETTINGS, settings);
443
+ writeJsonAtomic(CLAUDE_SETTINGS, settings);
494
444
  });
495
445
  await installSkill("claude-code", CLAUDE_SKILL_DIR);
496
446
  console.log();
497
- console.log(` ${dim2("What this configured:")}`);
498
- console.log(` ${dim2("\u2022")} MCP server: your agent can send notifications and ask questions`);
499
- console.log(` ${dim2("\u2022")} Skill: teaches your agent when and how to use Pushary`);
500
- console.log(` ${dim2("\u2022")} Hooks: route permission approvals through push notifications`);
501
- console.log(` ${dim2("\u2022")} Auto-allowed tools: no permission prompts for Pushary MCP calls`);
447
+ console.log(` ${dim("What this configured:")}`);
448
+ console.log(` ${dim("\u2022")} MCP server: your agent can send notifications and ask questions`);
449
+ console.log(` ${dim("\u2022")} Skill: teaches your agent when and how to use Pushary`);
450
+ console.log(` ${dim("\u2022")} Hooks: route permission approvals through push notifications`);
451
+ console.log(` ${dim("\u2022")} Auto-allowed tools: no permission prompts for Pushary MCP calls`);
502
452
  };
453
+ var IS_WINDOWS = process.platform === "win32";
503
454
  var resolveHermesPython = () => {
504
455
  try {
505
- const launcher = execSync2("command -v hermes", { encoding: "utf-8", stdio: "pipe", timeout: 5e3 }).trim();
506
- if (launcher) {
456
+ const probe = IS_WINDOWS ? "where hermes" : "command -v hermes";
457
+ const launcher = execSync2(probe, { encoding: "utf-8", stdio: "pipe", timeout: 5e3 }).split("\n")[0].trim();
458
+ if (launcher && !IS_WINDOWS) {
507
459
  const shebang = readFileSync(launcher, "utf-8").split("\n", 1)[0];
508
460
  if (shebang.startsWith("#!")) {
509
461
  const interpreter = shebang.slice(2).trim().split(/\s+/)[0];
@@ -512,9 +464,9 @@ var resolveHermesPython = () => {
512
464
  }
513
465
  } catch {
514
466
  }
515
- const venvPython = join(homedir(), ".hermes", "hermes-agent", "venv", "bin", "python3");
516
- if (existsSync(venvPython)) return venvPython;
517
- return null;
467
+ const venvRoot = join(homedir(), ".hermes", "hermes-agent", "venv");
468
+ const candidates = IS_WINDOWS ? [join(venvRoot, "Scripts", "python.exe"), join(venvRoot, "Scripts", "python3.exe")] : [join(venvRoot, "bin", "python3"), join(venvRoot, "bin", "python")];
469
+ return candidates.find(existsSync) ?? null;
518
470
  };
519
471
  var ensurePip = (python) => {
520
472
  try {
@@ -529,27 +481,34 @@ var ensurePip = (python) => {
529
481
  };
530
482
  var enablePusharyPlugin = (python) => {
531
483
  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"] = (e + ["pushary"]) if "pushary" not in e else e; 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"] = (d + ["clarify"]) if "clarify" not in d else d; c["agent"] = a; save_config(c)';
484
+ const scriptPath = join(tmpdir(), `pushary-hermes-${process.pid}.py`);
532
485
  try {
533
- execSync2(`"${python}" -c '${snippet}'`, { stdio: "pipe", timeout: 15e3 });
486
+ writeFileSync(scriptPath, snippet.split("; ").join("\n"), "utf-8");
487
+ execSync2(`"${python}" "${scriptPath}"`, { stdio: "pipe", timeout: 15e3 });
534
488
  return;
535
489
  } catch {
490
+ execSync2("hermes plugins enable pushary", { stdio: "ignore", timeout: 1e4 });
491
+ } finally {
492
+ try {
493
+ rmSync(scriptPath, { force: true });
494
+ } catch {
495
+ }
536
496
  }
537
- execSync2("hermes plugins enable pushary", { stdio: "ignore", timeout: 1e4 });
538
497
  };
539
498
  var setupHermes = async (_apiKey) => {
540
499
  console.log(`
541
- ${bold2("Setting up Hermes Agent")}
500
+ ${bold("Setting up Hermes Agent")}
542
501
  `);
543
502
  if (!isInstalled("hermes")) {
544
- console.log(` ${yellow2("!")} Hermes CLI not found. Skipping.`);
545
- console.log(` ${dim2("Install Hermes and re-run setup to configure.")}`);
546
- return;
503
+ console.log(` ${yellow("!")} Hermes CLI not found. Skipping.`);
504
+ console.log(` ${dim("Install Hermes and re-run setup to configure.")}`);
505
+ return "skipped";
547
506
  }
548
507
  const python = resolveHermesPython();
549
508
  if (!python) {
550
- console.log(` ${yellow2("!")} Could not locate the Python that Hermes runs in. Skipping.`);
551
- console.log(` ${dim2("Install manually: <hermes-python> -m pip install hermes-plugin-pushary")}`);
552
- return;
509
+ console.log(` ${yellow("!")} Could not locate the Python that Hermes runs in. Skipping.`);
510
+ noteManual("Install the Hermes plugin by hand: <hermes-python> -m pip install hermes-plugin-pushary");
511
+ return "skipped";
553
512
  }
554
513
  await spinner("Installing hermes-plugin-pushary into Hermes\u2019 environment", async () => {
555
514
  ensurePip(python);
@@ -559,12 +518,12 @@ var setupHermes = async (_apiKey) => {
559
518
  enablePusharyPlugin(python);
560
519
  });
561
520
  console.log();
562
- console.log(` ${dim2("What this configured:")}`);
563
- console.log(` ${dim2("\u2022")} Native tools: pushary_notify, pushary_ask, pushary_wait, pushary_cancel`);
564
- console.log(` ${dim2("\u2022")} Questions go to your phone: Hermes' terminal ${bold2("clarify")} tool is disabled, so pushary_ask handles every question`);
565
- console.log(` ${dim2("\u2022")} Auto-notifications: push alert when tools return errors`);
566
- console.log(` ${dim2("\u2022")} Permission gating: set ${bold2("PUSHARY_GATE_TOOLS")} to require lock-screen approval for risky tools`);
567
- console.log(` ${dim2("To re-enable terminal prompts:")} remove ${bold2("clarify")} from ${dim2("agent.disabled_toolsets")} in ~/.hermes/config.yaml`);
521
+ console.log(` ${dim("What this configured:")}`);
522
+ console.log(` ${dim("\u2022")} Native tools: pushary_notify, pushary_ask, pushary_wait, pushary_cancel`);
523
+ console.log(` ${dim("\u2022")} Questions go to your phone: Hermes' terminal ${bold("clarify")} tool is disabled, so pushary_ask handles every question`);
524
+ console.log(` ${dim("\u2022")} Auto-notifications: push alert when tools return errors`);
525
+ console.log(` ${dim("\u2022")} Permission gating: set ${bold("PUSHARY_GATE_TOOLS")} to require lock-screen approval for risky tools`);
526
+ console.log(` ${dim("To re-enable terminal prompts:")} remove ${bold("clarify")} from ${dim("agent.disabled_toolsets")} in ~/.hermes/config.yaml`);
568
527
  };
569
528
  var CODEX_HOOKS_JSON = join(CODEX_HOME, "hooks.json");
570
529
  var CODEX_HOOKS_MIN_VERSION = [0, 122, 0];
@@ -625,24 +584,19 @@ var addCodexNotifyEntry = (codexConfig) => {
625
584
  };
626
585
  var setupCodex = async (apiKey) => {
627
586
  console.log(`
628
- ${bold2("Setting up Codex")}
587
+ ${bold("Setting up Codex")}
629
588
  `);
630
589
  if (!isInstalled("codex")) {
631
- console.log(` ${yellow2("!")} Codex CLI not found. Skipping.`);
632
- console.log(` ${dim2("Install Codex and re-run setup to configure.")}`);
633
- return;
590
+ console.log(` ${yellow("!")} Codex CLI not found. Skipping.`);
591
+ console.log(` ${dim("Install Codex and re-run setup to configure.")}`);
592
+ return "skipped";
634
593
  }
635
- await installGlobally();
636
- const codexConfig = join(homedir(), ".codex", "config.toml");
594
+ await installGlobally2();
595
+ const codexConfig = codexConfigToml();
637
596
  await spinner("Adding Pushary MCP server (key embedded, auto-allowed)", async () => {
638
- let raw = "";
639
- try {
640
- raw = readFileSync(codexConfig, "utf-8");
641
- } catch {
642
- }
643
- const config = raw ? parseTOML(raw) : {};
597
+ const config = readAgentToml(codexConfig);
644
598
  addCodexMcpServer(config, apiKey);
645
- writeFileSync(codexConfig, stringifyTOML(config), "utf-8");
599
+ writeFileAtomic(codexConfig, stringifyTOML(config), KEY_FILE_MODE);
646
600
  });
647
601
  const codexVersion = readCodexVersion();
648
602
  const hooksSupported = codexSupportsHooks(codexVersion);
@@ -651,7 +605,7 @@ var setupCodex = async (apiKey) => {
651
605
  if (hooksSupported) {
652
606
  const hookCommand = quoteIfNeeded(resolveGlobalBinary("pushary-codex-hook") ?? "pushary-codex-hook");
653
607
  await spinner("Adding native hooks (~/.codex/hooks.json)", async () => {
654
- const hooksConfig = readJson(CODEX_HOOKS_JSON);
608
+ const hooksConfig = readAgentJson(CODEX_HOOKS_JSON);
655
609
  addCodexHooks(hooksConfig, hookCommand);
656
610
  writeJson(CODEX_HOOKS_JSON, hooksConfig);
657
611
  });
@@ -672,9 +626,9 @@ var setupCodex = async (apiKey) => {
672
626
  removeCodexNotifyEntry(codexConfig);
673
627
  });
674
628
  } else {
675
- console.log(` ${yellow2("!")} This Codex version predates native hooks (needs ${CODEX_HOOKS_MIN_VERSION.join(".")}+).`);
676
- console.log(` ${dim2("Installing the deprecated notify handler instead. Upgrade Codex and re-run setup")}`);
677
- console.log(` ${dim2("to get policy enforcement, phone approvals, and session tracking.")}`);
629
+ console.log(` ${yellow("!")} This Codex version predates native hooks (needs ${CODEX_HOOKS_MIN_VERSION.join(".")}+).`);
630
+ console.log(` ${dim("Installing the deprecated notify handler instead. Upgrade Codex and re-run setup")}`);
631
+ console.log(` ${dim("to get policy enforcement, phone approvals, and session tracking.")}`);
678
632
  await spinner("Adding notify handler for Codex events (deprecated)", async () => {
679
633
  addCodexNotifyEntry(codexConfig);
680
634
  });
@@ -684,30 +638,31 @@ var setupCodex = async (apiKey) => {
684
638
  writeInstructionBlock(CODEX_AGENTS_MD, renderAgentInstructions("Codex"));
685
639
  });
686
640
  console.log();
687
- console.log(` ${dim2("What this configured:")}`);
688
- console.log(` ${dim2("\u2022")} MCP server: Codex can send notifications and ask questions`);
689
- console.log(` ${dim2("\u2022")} API key embedded in config.toml: works without a shell reload or export`);
690
- console.log(` ${dim2("\u2022")} Proactive instructions: ~/.codex/AGENTS.md tells Codex to ask via push, not the terminal`);
691
- console.log(` ${dim2("\u2022")} Auto-allowed tools: no permission prompts for Pushary MCP calls`);
641
+ console.log(` ${dim("What this configured:")}`);
642
+ console.log(` ${dim("\u2022")} MCP server: Codex can send notifications and ask questions`);
643
+ console.log(` ${dim("\u2022")} API key embedded in config.toml: works without a shell reload or export`);
644
+ console.log(` ${dim("\u2022")} Proactive instructions: ~/.codex/AGENTS.md tells Codex to ask via push, not the terminal`);
645
+ console.log(` ${dim("\u2022")} Auto-allowed tools: no permission prompts for Pushary MCP calls`);
692
646
  if (hooksSupported) {
693
- console.log(` ${dim2("\u2022")} Native hooks: phone approvals, policy enforcement, kill switch, session tracking`);
647
+ console.log(` ${dim("\u2022")} Native hooks: phone approvals, policy enforcement, kill switch, session tracking`);
694
648
  if (trusted) {
695
- console.log(` ${dim2("\u2022")} Hook pre-trusted: no ${cyan2("/hooks")} step, phone approvals work right away`);
649
+ console.log(` ${dim("\u2022")} Hook pre-trusted: no ${cyan("/hooks")} step, phone approvals work right away`);
696
650
  } else {
697
651
  console.log();
698
- console.log(` ${bold2("One step left, and it happens inside Codex")}`);
699
- console.log(` ${dim2("Codex makes you trust any hook before it runs. This is Codex's own")}`);
700
- console.log(` ${dim2("security check, not a Pushary thing, and you only do it once.")}`);
652
+ console.log(` ${bold("One step left, and it happens inside Codex")}`);
653
+ console.log(` ${dim("Codex makes you trust any hook before it runs. This is Codex's own")}`);
654
+ console.log(` ${dim("security check, not a Pushary thing, and you only do it once.")}`);
701
655
  console.log();
702
- console.log(` ${cyan2("1.")} Open Codex and type ${cyan2("/hooks")}`);
703
- console.log(` ${cyan2("2.")} You will see the Pushary hook commands. Trust them.`);
656
+ console.log(` ${cyan("1.")} Open Codex and type ${cyan("/hooks")}`);
657
+ console.log(` ${cyan("2.")} You will see the Pushary hook commands. Trust them.`);
704
658
  console.log();
705
- console.log(` ${dim2("Until you do, Codex just runs as usual without the phone approvals.")}`);
706
- console.log(` ${dim2("To confirm:")} run a command that needs approval and watch your phone.`);
707
- console.log(` ${dim2("Pushary updates keep the same trust, so Codex will not ask again.")}`);
659
+ console.log(` ${dim("Until you do, Codex just runs as usual without the phone approvals.")}`);
660
+ console.log(` ${dim("To confirm:")} run a command that needs approval and watch your phone.`);
661
+ console.log(` ${dim("Pushary updates keep the same trust, so Codex will not ask again.")}`);
662
+ noteManual("Open Codex, type /hooks, and trust the Pushary hook. Until then Codex runs without phone approvals.");
708
663
  }
709
664
  } else {
710
- console.log(` ${dim2("\u2022")} Notify handler (deprecated): captures turn completions and approval requests`);
665
+ console.log(` ${dim("\u2022")} Notify handler (deprecated): captures turn completions and approval requests`);
711
666
  }
712
667
  };
713
668
  var resolveBundledPlugin = () => {
@@ -753,25 +708,44 @@ var neutralizePluginGate = () => {
753
708
  };
754
709
  var setupCursor = async (apiKey) => {
755
710
  console.log(`
756
- ${bold2("Setting up Cursor")}
711
+ ${bold("Setting up Cursor")}
757
712
  `);
758
713
  const source = resolveBundledPlugin();
759
714
  if (!source) throw new Error("bundled Cursor plugin not found in this package");
760
715
  await spinner("Installing Pushary plugin", async () => {
761
- rmSync(CURSOR_PLUGIN_DIR, { recursive: true, force: true });
762
- cpSync(source, CURSOR_PLUGIN_DIR, {
763
- recursive: true,
764
- filter: (p) => !["tools", "node_modules", ".git", ".DS_Store"].includes(basename(p))
765
- });
716
+ const staging = join(dirname(CURSOR_PLUGIN_DIR), `.pushary-staging-${process.pid}`);
717
+ const backup = `${CURSOR_PLUGIN_DIR}.pushary-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
718
+ rmSync(staging, { recursive: true, force: true });
719
+ try {
720
+ cpSync(source, staging, {
721
+ recursive: true,
722
+ filter: (p) => !["tools", "node_modules", ".git", ".DS_Store"].includes(basename(p))
723
+ });
724
+ const staged = readJsonSafe(join(staging, ".cursor-plugin", "plugin.json"));
725
+ if (staged.kind !== "ok") {
726
+ throw new Error("staged Cursor plugin is missing or has an unreadable plugin.json");
727
+ }
728
+ const hadExisting = existsSync(CURSOR_PLUGIN_DIR);
729
+ if (hadExisting) renameSync(CURSOR_PLUGIN_DIR, backup);
730
+ try {
731
+ renameSync(staging, CURSOR_PLUGIN_DIR);
732
+ } catch (err) {
733
+ if (hadExisting) renameSync(backup, CURSOR_PLUGIN_DIR);
734
+ throw err;
735
+ }
736
+ if (hadExisting) rmSync(backup, { recursive: true, force: true });
737
+ } finally {
738
+ rmSync(staging, { recursive: true, force: true });
739
+ }
766
740
  });
767
741
  await spinner("Linking your API key", async () => {
768
742
  const mcpPath = join(CURSOR_PLUGIN_DIR, "mcp.json");
769
- const mcp = readJson(mcpPath);
743
+ const mcp = readAgentJson(mcpPath);
770
744
  const servers = mcp.mcpServers ?? {};
771
745
  if (servers.pushary) {
772
746
  servers.pushary.headers = { ...servers.pushary.headers, Authorization: `Bearer ${apiKey}` };
773
747
  mcp.mcpServers = servers;
774
- writeJson(mcpPath, mcp);
748
+ writeJsonAtomic(mcpPath, mcp, KEY_FILE_MODE);
775
749
  }
776
750
  });
777
751
  await spinner("Registering permission gate (~/.cursor/hooks.json)", async () => {
@@ -779,59 +753,77 @@ var setupCursor = async (apiKey) => {
779
753
  neutralizePluginGate();
780
754
  });
781
755
  console.log();
782
- console.log(` ${dim2("What this configured:")}`);
783
- console.log(` ${dim2("\u2022")} Plugin installed to ~/.cursor/plugins/local/pushary (MCP tools, rule, skill)`);
784
- console.log(` ${dim2("\u2022")} Permission gate registered once in ~/.cursor/hooks.json`);
785
- console.log(` ${dim2("\u2022")} Risky shell commands route to push approval before they run`);
786
- console.log(` ${dim2("\u2022")} Fully quit and reopen Cursor to load it (a Reload Window may not be enough)`);
756
+ console.log(` ${dim("What this configured:")}`);
757
+ console.log(` ${dim("\u2022")} Plugin installed to ~/.cursor/plugins/local/pushary (MCP tools, rule, skill)`);
758
+ console.log(` ${dim("\u2022")} Permission gate registered once in ~/.cursor/hooks.json`);
759
+ console.log(` ${dim("\u2022")} Risky shell commands route to push approval before they run`);
760
+ console.log(` ${dim("\u2022")} Fully quit and reopen Cursor to load it (a Reload Window may not be enough)`);
761
+ noteManual("Fully quit and reopen Cursor. A Reload Window may not be enough.");
787
762
  };
788
763
  var saveApiKey = async (apiKey) => {
764
+ let result;
789
765
  await spinner("Saving your API key", async () => {
790
- if (!existsSync(PUSHARY_CONFIG_DIR)) mkdirSync(PUSHARY_CONFIG_DIR, { recursive: true });
791
- const config = readJson(PUSHARY_CONFIG_FILE);
792
- writeFileSync(PUSHARY_CONFIG_FILE, JSON.stringify({ ...config, apiKey }, null, 2) + "\n", "utf-8");
793
- try {
794
- chmodSync(PUSHARY_CONFIG_FILE, 384);
795
- } catch {
796
- }
797
- const exportLine = `
798
- export PUSHARY_API_KEY='${apiKey}'
799
- `;
800
- const shellFile = SHELL_FILES.find((f) => existsSync(f));
801
- if (shellFile) {
802
- const content = readFileSync(shellFile, "utf-8");
803
- if (!content.includes("PUSHARY_API_KEY")) {
804
- appendFileSync(shellFile, exportLine, "utf-8");
805
- }
806
- }
807
- process.env.PUSHARY_API_KEY = apiKey;
766
+ result = writeKey(apiKey);
808
767
  });
768
+ if (!result) throw new Error("Could not save the API key");
769
+ if (result.configBackupPath) {
770
+ console.log(` ${yellow("!")} ${PUSHARY_CONFIG_FILE} did not parse. Saved a copy at ${dim(result.configBackupPath)}`);
771
+ }
772
+ const rc = result.rc;
773
+ switch (rc.kind) {
774
+ case "updated":
775
+ console.log(` ${check} Replaced the old key in ${dim(rc.path ?? "")}${rc.previousKeyPrefix ? dim(` (was ${rc.previousKeyPrefix})`) : ""}`);
776
+ break;
777
+ case "declined":
778
+ console.log(` ${yellow("!")} ${rc.path}:${rc.declinedLine} already sets ${cyan("PUSHARY_API_KEY")} in a form Pushary did not write.`);
779
+ console.log(` ${dim("It was left alone, and it will shadow the new key in new shells. Update it to:")}`);
780
+ console.log(` ${cyan(exportLine(apiKey))}`);
781
+ break;
782
+ case "unavailable":
783
+ console.log(` ${yellow("!")} Could not update ${rc.path}${rc.detail ? dim(` (${rc.detail})`) : ""}. Add this line yourself:`);
784
+ console.log(` ${cyan(exportLine(apiKey))}`);
785
+ break;
786
+ case "no-rc":
787
+ console.log(` ${yellow("!")} No shell profile found for this shell. Add this line yourself:`);
788
+ console.log(` ${cyan(exportLine(apiKey))}`);
789
+ break;
790
+ case "written":
791
+ case "unchanged":
792
+ break;
793
+ }
794
+ return rc;
809
795
  };
810
- var setupCustom = async (_apiKey) => {
796
+ var setupCustom = async (apiKey, context) => {
811
797
  console.log(`
812
- ${bold2("Connect any other agent (MCP or HTTP)")}
798
+ ${bold("Connect any other agent (MCP or HTTP)")}
813
799
  `);
814
- console.log(` ${dim2("Your key is saved as")} ${cyan2("$PUSHARY_API_KEY")} ${dim2("in your shell profile.")}`);
800
+ const wroteExport = context ? ["written", "updated", "unchanged"].includes(context.rc.kind) : true;
801
+ if (wroteExport) {
802
+ console.log(` ${dim("Your key is saved as")} ${cyan("$PUSHARY_API_KEY")} ${dim("in your shell profile.")}`);
803
+ } else {
804
+ console.log(` ${dim("Add this to your shell profile so")} ${cyan("$PUSHARY_API_KEY")} ${dim("resolves:")}`);
805
+ console.log(` ${cyan(exportLine(apiKey))}`);
806
+ }
815
807
  console.log();
816
- console.log(` ${dim2("MCP clients (Windsurf, LangChain, custom) \u2014 add this server:")}`);
817
- console.log(` ${cyan2("https://pushary.com/api/mcp/mcp")} ${dim2("with header")} ${cyan2("Authorization: Bearer $PUSHARY_API_KEY")}`);
808
+ console.log(` ${dim("MCP clients (Windsurf, LangChain, custom) \u2014 add this server:")}`);
809
+ console.log(` ${cyan("https://pushary.com/api/mcp/mcp")} ${dim("with header")} ${cyan("Authorization: Bearer $PUSHARY_API_KEY")}`);
818
810
  console.log();
819
- console.log(` ${dim2("No-code / HTTP (n8n, Zapier, scripts) \u2014 ask a human and wait:")}`);
820
- console.log(` ${dim2("POST")} ${cyan2("https://pushary.com/api/v1/server/ask")} ${dim2("body")} ${cyan2('{"question":"Approve?","type":"confirm","wait":true}')}`);
811
+ console.log(` ${dim("No-code / HTTP (n8n, Zapier, scripts) \u2014 ask a human and wait:")}`);
812
+ console.log(` ${dim("POST")} ${cyan("https://pushary.com/api/v1/server/ask")} ${dim("body")} ${cyan('{"question":"Approve?","type":"confirm","wait":true}')}`);
821
813
  console.log();
822
- console.log(` ${dim2("Full guide:")} ${cyan2("https://pushary.com/docs/agents/connect-any-agent")}`);
814
+ console.log(` ${dim("Full guide:")} ${cyan("https://pushary.com/docs/agents/connect-any-agent")}`);
823
815
  };
824
816
  var setupGemini = async (apiKey) => {
825
817
  console.log(`
826
- ${bold2("Setting up Gemini CLI")}
818
+ ${bold("Setting up Gemini CLI")}
827
819
  `);
828
820
  if (!isInstalled("gemini")) {
829
- console.log(` ${yellow2("!")} Gemini CLI not found. Skipping.`);
830
- console.log(` ${dim2("Install Gemini CLI and re-run setup to configure.")}`);
831
- return;
821
+ console.log(` ${yellow("!")} Gemini CLI not found. Skipping.`);
822
+ console.log(` ${dim("Install Gemini CLI and re-run setup to configure.")}`);
823
+ return "skipped";
832
824
  }
833
- await installGlobally();
834
- const settings = readJson(GEMINI_SETTINGS);
825
+ await installGlobally2();
826
+ const settings = readAgentJson(GEMINI_SETTINGS);
835
827
  await spinner("Adding MCP server (~/.gemini/settings.json)", async () => {
836
828
  addGeminiMcpServer(settings, apiKey);
837
829
  });
@@ -839,19 +831,20 @@ var setupGemini = async (apiKey) => {
839
831
  addGeminiHooks(settings, quoteIfNeeded(resolveGlobalBinary(GEMINI_HOOK_BINARY) ?? GEMINI_HOOK_BINARY));
840
832
  });
841
833
  await spinner(`Writing ${GEMINI_SETTINGS}`, async () => {
842
- writeJson(GEMINI_SETTINGS, settings);
834
+ writeJsonAtomic(GEMINI_SETTINGS, settings, KEY_FILE_MODE);
843
835
  });
844
836
  await spinner("Teaching Gemini to ask via push (~/.gemini/GEMINI.md)", async () => {
845
837
  writeInstructionBlock(GEMINI_MD, renderAgentInstructions("Gemini"));
846
838
  });
847
839
  console.log();
848
- console.log(` ${dim2("What this configured:")}`);
849
- console.log(` ${dim2("\u2022")} MCP server: Gemini can send notifications and ask questions`);
850
- console.log(` ${dim2("\u2022")} Proactive instructions: ~/.gemini/GEMINI.md tells Gemini to ask via push, not the terminal`);
851
- console.log(` ${dim2("\u2022")} Hooks: phone approvals, policy enforcement, kill switch, session tracking`);
852
- console.log(` ${dim2("\u2022")} Gate covers ${bold2("run_shell_command")}, ${bold2("write_file")}, and ${bold2("replace")}`);
853
- console.log(` ${dim2("\u2022")} Auto-allowed tools: Pushary MCP calls run without a confirmation prompt`);
854
- console.log(` ${dim2("Restart Gemini CLI to load the new config.")}`);
840
+ console.log(` ${dim("What this configured:")}`);
841
+ console.log(` ${dim("\u2022")} MCP server: Gemini can send notifications and ask questions`);
842
+ console.log(` ${dim("\u2022")} Proactive instructions: ~/.gemini/GEMINI.md tells Gemini to ask via push, not the terminal`);
843
+ console.log(` ${dim("\u2022")} Hooks: phone approvals, policy enforcement, kill switch, session tracking`);
844
+ console.log(` ${dim("\u2022")} Gate covers ${bold("run_shell_command")}, ${bold("write_file")}, and ${bold("replace")}`);
845
+ console.log(` ${dim("\u2022")} Auto-allowed tools: Pushary MCP calls run without a confirmation prompt`);
846
+ console.log(` ${dim("Restart Gemini CLI to load the new config.")}`);
847
+ noteManual("Restart Gemini CLI to load the new config.");
855
848
  };
856
849
  var AGENT_SETUP = {
857
850
  claude_code: setupClaudeCode,
@@ -865,9 +858,9 @@ var PROJECT_INSTRUCTION_TARGETS = {
865
858
  claude_code: { file: "CLAUDE.md", label: "Claude Code" },
866
859
  codex: { file: "AGENTS.md", label: "Codex" }
867
860
  };
868
- var offerProjectInstructions = async (agents) => {
861
+ var offerProjectInstructions = async (agents, options) => {
869
862
  const targets = agents.map((agent) => PROJECT_INSTRUCTION_TARGETS[agent]).filter((target) => target !== void 0);
870
- if (targets.length === 0 || !process.stdout.isTTY) return;
863
+ if (targets.length === 0 || !process.stdout.isTTY || options.nonInteractive) return;
871
864
  console.log();
872
865
  const wanted = await confirm({
873
866
  message: "Add Pushary instructions to this project's CLAUDE.md/AGENTS.md so your whole team's agents can use phone approvals?",
@@ -879,129 +872,306 @@ var offerProjectInstructions = async (agents) => {
879
872
  writeInstructionBlock(join(process.cwd(), target.file), renderProjectAgentInstructions(target.label));
880
873
  }, { optional: true });
881
874
  }
882
- console.log(` ${dim2("Commit the file to share it. Teammates without a key fall back to the terminal.")}`);
875
+ console.log(` ${dim("Commit the file to share it. Teammates without a key fall back to the terminal.")}`);
883
876
  };
884
- var offerClaudeAlias = async () => {
885
- if (!process.stdout.isTTY) return;
877
+ var offerClaudeAlias = async (options) => {
878
+ if (!process.stdout.isTTY || options.nonInteractive) return;
886
879
  const rc = resolveShellRc();
887
880
  console.log();
888
881
  if (!rc) {
889
- console.log(` ${dim2("Tip: alias")} ${cyan2("claude='pushary claude'")} ${dim2("in your shell to make plain")} ${cyan2("claude")} ${dim2("reachable from your phone.")}`);
882
+ console.log(` ${dim("Tip: alias")} ${cyan("claude='pushary claude'")} ${dim("in your shell to make plain")} ${cyan("claude")} ${dim("reachable from your phone.")}`);
890
883
  return;
891
884
  }
892
885
  const wanted = await confirm({
893
- message: `Make plain ${bold2("claude")} reachable from your phone by default? (adds an alias to ${basename(rc.path)}, reversible)`,
886
+ message: `Make plain ${bold("claude")} reachable from your phone by default? (adds an alias to ${basename(rc.path)}, reversible)`,
894
887
  default: false
895
888
  });
896
889
  if (!wanted) return;
897
890
  const result = installClaudeAlias();
898
891
  if (result.installed) {
899
- console.log(` ${check2} Aliased ${cyan2("claude")} \u2192 ${cyan2("pushary claude")} in ${dim2(result.path ?? rc.path)}`);
900
- console.log(` ${dim2("Open a new terminal (or")} ${cyan2(`source ${rc.path}`)}${dim2(") to use it. Undo any time with")} ${cyan2("pushary clean")}${dim2(".")}`);
892
+ console.log(` ${check} Aliased ${cyan("claude")} \u2192 ${cyan("pushary claude")} in ${dim(result.path ?? rc.path)}`);
893
+ console.log(` ${dim("Open a new terminal (or")} ${cyan(`source ${rc.path}`)}${dim(") to use it. Undo any time with")} ${cyan("pushary clean")}${dim(".")}`);
901
894
  } else if (result.reason === "exists") {
902
- console.log(` ${check2} ${dim2("The")} ${cyan2("claude")} ${dim2("alias is already set.")}`);
895
+ console.log(` ${check} ${dim("The")} ${cyan("claude")} ${dim("alias is already set.")}`);
896
+ }
897
+ };
898
+ var verifyKeyOrExit = async (apiKey, options) => {
899
+ const live = process.stdout.isTTY === true && !options.json;
900
+ if (live) writeHuman(` ${dim("...")} Verifying your key with pushary.com`);
901
+ let result;
902
+ try {
903
+ result = await checkApiKey(apiKey);
904
+ } catch (err) {
905
+ result = { kind: "unreachable", detail: formatError(err) };
906
+ }
907
+ if (live) writeHuman("\r\x1B[2K");
908
+ const report = describeKeyCheck(result);
909
+ if (report.fatal) {
910
+ console.log(` ${yellow("!")} ${report.message}`);
911
+ for (const line of report.next) console.log(` ${dim(line)}`);
912
+ console.log();
913
+ process.exit(report.exitCode);
903
914
  }
915
+ if (result.kind === "ok") {
916
+ const { siteSlug, siteName, plan } = result.identity;
917
+ console.log(` ${check} Key verified ${dim(`(${siteSlug || siteName} \xB7 plan ${plan})`)}`);
918
+ } else {
919
+ console.log(` ${yellow("!")} ${report.message}`);
920
+ for (const line of report.next) console.log(` ${dim(line)}`);
921
+ }
922
+ return result;
923
+ };
924
+ var AGENT_NAMES = {
925
+ claude_code: "Claude Code",
926
+ codex: "Codex",
927
+ gemini_cli: "Gemini CLI",
928
+ hermes: "Hermes",
929
+ cursor: "Cursor",
930
+ custom: "Other"
931
+ };
932
+ var AGENT_CAPABILITIES = {
933
+ claude_code: "MCP + hooks + auto-allowed tools",
934
+ codex: "MCP + native hooks + auto-allowed tools",
935
+ gemini_cli: "MCP + native hooks + auto-allowed tools",
936
+ hermes: "native plugin + auto-error notifications",
937
+ cursor: "plugin + permission gate",
938
+ custom: "any MCP or HTTP agent (Windsurf, n8n, custom)"
939
+ };
940
+ var NAME_COLUMN = Math.max(...Object.values(AGENT_NAMES).map((name) => name.length)) + 2;
941
+ var AGENT_LABELS = Object.fromEntries(
942
+ Object.keys(AGENT_NAMES).map((agent) => [
943
+ agent,
944
+ `${AGENT_NAMES[agent].padEnd(NAME_COLUMN)}${dim(AGENT_CAPABILITIES[agent])}`
945
+ ])
946
+ );
947
+ var detectAgents = () => {
948
+ const map = {};
949
+ for (const entry of detectAllAgents()) {
950
+ map[entry.agent] = { found: isDetected(entry.result), evidence: describeDetection(entry.result) };
951
+ }
952
+ return map;
953
+ };
954
+ var canRunBrowserLogin = () => process.stdout.isTTY === true && !process.env.CI;
955
+ var resolveAgents = async (options) => {
956
+ if (options.agents.kind === "none") return [];
957
+ if (options.agents.kind === "explicit") {
958
+ const detected2 = detectAgents();
959
+ for (const agent of options.agents.agents) {
960
+ if (agent !== "custom" && !detected2[agent].found) {
961
+ console.log(` ${yellow("!")} ${AGENT_NAMES[agent]} was not detected on this machine.`);
962
+ console.log(` ${dim("Continuing anyway. The steps below say what was configured.")}`);
963
+ }
964
+ }
965
+ return options.agents.agents;
966
+ }
967
+ const detected = detectAgents();
968
+ if (options.agents.kind === "auto") {
969
+ const picked = Object.keys(detected).filter((agent) => detected[agent].found);
970
+ if (picked.length === 0) {
971
+ console.log(` ${yellow("!")} No agents detected, so nothing was configured. Your key is saved.`);
972
+ console.log(` ${dim("Install an agent and re-run, name one with --agents <id>, or pass --agents none to accept this.")}`);
973
+ console.log();
974
+ process.exit(EXIT.PROBLEMS_FOUND);
975
+ }
976
+ console.log(` ${check} Selected automatically: ${picked.map((a) => AGENT_NAMES[a]).join(", ")} ${dim("(--agents to choose)")}`);
977
+ return picked;
978
+ }
979
+ const hint = Object.values(detected).some((entry) => entry.found) ? "(detected agents pre-selected)" : "(space = toggle, enter = confirm)";
980
+ return checkbox({
981
+ message: "Which agents do you use? " + dim(hint),
982
+ choices: Object.keys(AGENT_LABELS).map((agent) => ({
983
+ // The evidence rides along so a detection the user disagrees with is
984
+ // visible and correctable, rather than an unexplained pre-tick.
985
+ name: detected[agent].evidence ? `${AGENT_LABELS[agent]} ${dim(`[${detected[agent].evidence}]`)}` : AGENT_LABELS[agent],
986
+ value: agent,
987
+ checked: detected[agent].found
988
+ })),
989
+ // Today an empty selection skips the loop and still prints "Setup complete."
990
+ validate: (picked) => picked.length > 0 || "Select at least one agent, or re-run with --agents none."
991
+ });
992
+ };
993
+ var AGENT_TARGETS = {
994
+ claude_code: [CLAUDE_JSON, CLAUDE_SETTINGS, join(CLAUDE_SKILL_DIR, "SKILL.md")],
995
+ codex: [join(CODEX_HOME, "config.toml"), CODEX_HOOKS_JSON, join(CODEX_SKILL_DIR, "SKILL.md"), CODEX_AGENTS_MD],
996
+ gemini_cli: [GEMINI_SETTINGS, GEMINI_MD],
997
+ hermes: ["the Hermes virtualenv (pip install pushary-hermes)"],
998
+ cursor: [join(CURSOR_PLUGIN_DIR, "mcp.json"), CURSOR_USER_HOOKS],
999
+ custom: ["nothing (prints connection details only)"]
1000
+ };
1001
+ var reportDryRun = (apiKey, agents, keyCheck) => {
1002
+ console.log();
1003
+ console.log(` ${bold("Dry run")} ${dim("\u2014 nothing below has been written")}`);
1004
+ console.log();
1005
+ console.log(` ${dim("Key:")} ${dim(`${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`)} ${dim(`(${keyCheck?.kind ?? "not verified"})`)}`);
1006
+ console.log(` ${dim("Key store:")} ${PUSHARY_CONFIG_FILE}`);
1007
+ const rc = resolveShellRc();
1008
+ console.log(` ${dim("Shell rc:")} ${rc ? rc.path : dim("none for this shell; the export line would be printed instead")}`);
1009
+ console.log();
1010
+ if (agents.length === 0) {
1011
+ console.log(` ${dim("Agents:")} ${dim("none")}`);
1012
+ } else {
1013
+ console.log(` ${dim("Agents:")} ${agents.map((a) => a.replace("_", " ")).join(", ")}`);
1014
+ for (const agent of agents) {
1015
+ console.log();
1016
+ console.log(` ${bold(agent.replace("_", " "))}`);
1017
+ for (const target of AGENT_TARGETS[agent]) console.log(` ${dim("would write")} ${target}`);
1018
+ }
1019
+ }
1020
+ console.log();
1021
+ console.log(` ${dim("Re-run without --dry-run to apply.")}`);
1022
+ console.log();
1023
+ };
1024
+ var printManualSteps = () => {
1025
+ if (manualSteps.length === 0) return;
1026
+ console.log(` ${bold("Still to do")}`);
1027
+ for (const step of manualSteps) console.log(` ${dim("\u2022")} ${step}`);
1028
+ console.log();
1029
+ };
1030
+ var emitJson = (options, payload) => {
1031
+ if (!options.json) return;
1032
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
1033
+ `);
904
1034
  };
905
1035
  var main = async () => {
1036
+ const options = parseSetupOptions({
1037
+ argv: process.argv.slice(2),
1038
+ stdinIsTty: process.stdin.isTTY === true
1039
+ });
1040
+ if (options.help) {
1041
+ process.stdout.write(`${renderCommandHelp("setup", getPackageVersion())}
1042
+ `);
1043
+ process.exit(EXIT.OK);
1044
+ }
1045
+ if (options.json) {
1046
+ humanStream = process.stderr;
1047
+ setHumanStream(process.stderr);
1048
+ console.log = (...args) => {
1049
+ process.stderr.write(`${args.map((a) => typeof a === "string" ? a : String(a)).join(" ")}
1050
+ `);
1051
+ };
1052
+ }
906
1053
  const version = getPackageVersion();
907
1054
  console.log();
908
- console.log(` ${bold2("Pushary")} ${dim2("v" + version)}`);
909
- console.log(` ${dim2("Push notifications for AI coding agents")}`);
1055
+ console.log(` ${bold("Pushary")} ${dim("v" + version)}`);
1056
+ console.log(` ${dim("Push notifications for AI agents")}`);
910
1057
  console.log();
911
1058
  await checkForUpdates(version);
912
- const connectMode = parseConnectFlag();
913
- const flagKey = parseKeyFlag();
1059
+ const connectMode = options.connectMode;
1060
+ const flagKey = await readKeyFromStdin(options) ?? options.key ?? void 0;
914
1061
  const envKey = process.env.PUSHARY_API_KEY?.trim();
915
1062
  let trimmedKey;
916
1063
  if (connectMode === "app") {
917
- console.log(` ${dim2("Pairing with your Pushary app.")}`);
1064
+ console.log(` ${dim("Pairing with your Pushary app.")}`);
918
1065
  const paired = await connectViaAppPairing();
919
1066
  if (!paired) {
920
1067
  console.log();
921
- console.log(` ${yellow2("!")} Pairing didn't complete. Re-run setup when you're ready to scan.`);
922
- console.log(` ${dim2("No app yet? Get it at")} ${cyan2("https://pushary.com/app")}`);
1068
+ console.log(` ${yellow("!")} Pairing didn't complete. Re-run setup when you're ready to scan.`);
1069
+ console.log(` ${dim("No app yet? Get it at")} ${cyan("https://pushary.com/download")}`);
923
1070
  process.exit(1);
924
1071
  }
925
1072
  trimmedKey = paired.apiKey;
926
1073
  if (paired.handle) {
927
- console.log(` ${check2} Connected to ${cyan2("@" + paired.handle)}`);
1074
+ console.log(` ${check} Connected to ${cyan("@" + paired.handle)}`);
928
1075
  }
929
1076
  console.log();
930
1077
  } else if (flagKey && isValidApiKey(flagKey)) {
931
1078
  const masked = `${flagKey.slice(0, 8)}...${flagKey.slice(-4)}`;
932
- console.log(` ${check2} Using API key: ${dim2(masked)}`);
1079
+ console.log(` ${check} Using API key: ${dim(masked)}`);
933
1080
  console.log();
934
1081
  trimmedKey = flagKey;
935
1082
  } else if (envKey && isValidApiKey(envKey)) {
936
1083
  const masked = `${envKey.slice(0, 8)}...${envKey.slice(-4)}`;
937
- console.log(` ${check2} Found API key in environment: ${dim2(masked)}`);
1084
+ console.log(` ${check} Found API key in environment: ${dim(masked)}`);
938
1085
  console.log();
939
- const useExisting = await confirm({ message: "Use this key?", default: true });
1086
+ const useExisting = options.nonInteractive ? true : await confirm({ message: "Use this key?", default: true });
940
1087
  if (useExisting) {
941
1088
  trimmedKey = envKey;
942
1089
  } else {
943
1090
  const apiKey = await input({ message: "API key:" });
944
1091
  trimmedKey = apiKey.trim();
945
1092
  }
946
- } else {
947
- console.log(` ${dim2("Paste your API key from the onboarding page.")}`);
948
- console.log(` ${dim2("Can't find it? Copy it from:")} ${cyan2("pushary.com/dashboard/agent/settings")}`);
1093
+ } else if (!canRunBrowserLogin()) {
1094
+ console.log(` ${yellow("!")} No API key, and no terminal to sign in from.`);
1095
+ console.log(` ${dim("Pass --key <pk_xxx.xxx>, or --key-stdin, or export PUSHARY_API_KEY.")}`);
1096
+ console.log(` ${dim("On a machine with a terminal, run")} ${cyan("pushary login")} ${dim("once and re-run this.")}`);
949
1097
  console.log();
950
- const apiKey = await input({ message: "API key:" });
951
- trimmedKey = apiKey.trim();
1098
+ process.exit(EXIT.NOT_CONFIGURED);
1099
+ } else {
1100
+ const outcome = await runBrowserLogin(
1101
+ { say: (line) => console.log(line), bold, dim, cyan, yellow },
1102
+ { cliVersion: version }
1103
+ );
1104
+ if (outcome.kind === "ok") {
1105
+ trimmedKey = outcome.apiKey;
1106
+ console.log();
1107
+ console.log(` ${check} Signed in${outcome.siteSlug ? ` ${dim(`(${outcome.siteSlug})`)}` : ""}`);
1108
+ console.log();
1109
+ } else if (outcome.kind === "rejected") {
1110
+ console.log();
1111
+ console.log(` ${yellow("!")} That request was rejected in the browser. Nothing was written.`);
1112
+ console.log();
1113
+ process.exit(EXIT.NOT_CONFIGURED);
1114
+ } else {
1115
+ console.log();
1116
+ if (outcome.kind === "unavailable") {
1117
+ console.log(` ${yellow("!")} Couldn't reach the login service ${dim(`(${outcome.detail})`)}`);
1118
+ } else {
1119
+ console.log(` ${yellow("!")} The code expired before it was approved.`);
1120
+ }
1121
+ console.log(` ${dim("Paste a key instead, or press Ctrl-C and re-run setup for a fresh code.")}`);
1122
+ console.log(` ${dim("Keys live at")} ${cyan("pushary.com/dashboard/agent/settings")}`);
1123
+ console.log();
1124
+ const apiKey = await input({ message: "API key:" });
1125
+ trimmedKey = apiKey.trim();
1126
+ }
952
1127
  }
953
1128
  if (!trimmedKey || !isValidApiKey(trimmedKey)) {
954
1129
  console.log(`
955
- ${yellow2("!")} Invalid key format. Expected: ${dim2("pk_xxx.xxx")}`);
956
- console.log(` ${dim2("Copy your key from")} ${cyan2("https://pushary.com/dashboard/agent/settings")}`);
957
- console.log(` ${dim2("Or sign up at")} ${cyan2("https://pushary.com/sign-up?from=ai-coding")}
1130
+ ${yellow("!")} Invalid key format. Expected: ${dim("pk_xxx.xxx")}`);
1131
+ console.log(` ${dim("Copy your key from")} ${cyan("https://pushary.com/dashboard/agent/settings")}`);
1132
+ console.log(` ${dim("Or sign up at")} ${cyan("https://pushary.com/sign-up?from=ai-coding")}
958
1133
  `);
959
- process.exit(1);
960
- }
961
- const detected = {
962
- claude_code: isInstalled("claude"),
963
- codex: isInstalled("codex"),
964
- gemini_cli: isInstalled("gemini"),
965
- hermes: isInstalled("hermes"),
966
- cursor: isInstalled("cursor"),
967
- custom: false
968
- };
969
- const hint = Object.values(detected).some(Boolean) ? "(detected agents pre-selected)" : "(space = toggle, enter = confirm)";
970
- const agents = await checkbox({
971
- message: "Which agents do you use? " + dim2(hint),
972
- choices: [
973
- { name: `Claude Code ${dim2("MCP + hooks + auto-allowed tools")}`, value: "claude_code", checked: detected.claude_code },
974
- { name: `Codex ${dim2("MCP + native hooks + auto-allowed tools")}`, value: "codex", checked: detected.codex },
975
- { name: `Gemini CLI ${dim2("MCP + native hooks + auto-allowed tools")}`, value: "gemini_cli", checked: detected.gemini_cli },
976
- { name: `Hermes ${dim2("native plugin + auto-error notifications")}`, value: "hermes", checked: detected.hermes },
977
- { name: `Cursor ${dim2("plugin + permission gate")}`, value: "cursor", checked: detected.cursor },
978
- { name: `Other ${dim2("any MCP or HTTP agent (Windsurf, n8n, custom)")}`, value: "custom", checked: false }
979
- ]
980
- });
981
- await saveApiKey(trimmedKey);
1134
+ process.exit(EXIT.USAGE);
1135
+ }
1136
+ const keyCheck = connectMode === "app" ? null : await verifyKeyOrExit(trimmedKey, options);
1137
+ const agents = await resolveAgents(options);
1138
+ if (options.dryRun) {
1139
+ reportDryRun(trimmedKey, agents, keyCheck);
1140
+ emitJson(options, { ok: true, dryRun: true, agents, keyCheck: keyCheck?.kind ?? "skipped" });
1141
+ process.exit(EXIT.OK);
1142
+ }
1143
+ const rc = await saveApiKey(trimmedKey);
1144
+ const context = { rc };
1145
+ const failed = [];
1146
+ const completed = [];
1147
+ const skipped = [];
982
1148
  if (agents.length === 0) {
983
1149
  console.log(`
984
- ${dim2("No agents selected. API key saved.")}`);
1150
+ ${check} API key saved. No agents configured.`);
1151
+ console.log(` ${dim("Run setup again with --agents <id> when you want one wired up.")}`);
985
1152
  } else {
986
- const failed = [];
987
- const completed = [];
988
1153
  for (const agent of agents) {
989
1154
  try {
990
- await AGENT_SETUP[agent](trimmedKey);
991
- completed.push(agent);
1155
+ const outcome = await AGENT_SETUP[agent](trimmedKey, context);
1156
+ if (outcome === "skipped") skipped.push(agent);
1157
+ else completed.push(agent);
992
1158
  } catch (err) {
993
1159
  failed.push(agent.replace("_", " "));
994
- console.log(` ${yellow2("!")} ${agent.replace("_", " ")} setup failed: ${formatError(err)}`);
995
- console.log(` ${dim2("Other agents will continue. Re-run setup to retry.")}`);
1160
+ console.log(` ${yellow("!")} ${agent.replace("_", " ")} setup failed: ${formatError(err)}`);
1161
+ console.log(` ${dim("Other agents will continue. Re-run setup to retry.")}`);
996
1162
  }
997
1163
  }
1164
+ if (skipped.length > 0) {
1165
+ console.log();
1166
+ console.log(` ${yellow("!")} Not configured: ${skipped.map((a) => a.replace("_", " ")).join(", ")} ${dim("(not installed on this machine)")}`);
1167
+ }
998
1168
  if (failed.length > 0) {
999
1169
  console.log();
1000
- console.log(` ${yellow2("!")} Failed: ${failed.join(", ")} ${dim2("(others completed successfully)")}`);
1170
+ console.log(` ${yellow("!")} Failed: ${failed.join(", ")} ${dim("(others completed successfully)")}`);
1001
1171
  }
1002
- await offerProjectInstructions(agents);
1172
+ await offerProjectInstructions(agents, options);
1003
1173
  if (completed.includes("claude_code")) {
1004
- await offerClaudeAlias();
1174
+ await offerClaudeAlias(options);
1005
1175
  }
1006
1176
  if (completed.length > 0) {
1007
1177
  try {
@@ -1013,26 +1183,107 @@ var main = async () => {
1013
1183
  }
1014
1184
  }
1015
1185
  }
1016
- if (connectMode !== "app") {
1017
- const skipPhone = process.argv.includes("--skip-phone");
1018
- if (skipPhone || !process.stdout.isTTY) {
1019
- await printConnectInstructions(trimmedKey);
1020
- } else {
1021
- await connectDevice(trimmedKey);
1022
- }
1186
+ const skipPhone = options.skipPhone;
1187
+ let phone = null;
1188
+ if (keyCheck?.kind === "locked") {
1189
+ console.log();
1190
+ console.log(` ${dim("Skipping the delivery check: no approval can be delivered until the plan is active.")}`);
1191
+ } else if (skipPhone) {
1192
+ console.log();
1193
+ console.log(` ${dim("Skipping the phone check (--skip-phone).")}`);
1194
+ } else if (!process.stdout.isTTY) {
1195
+ phone = await printConnectInstructions(trimmedKey);
1196
+ } else if (connectMode === "app") {
1197
+ phone = await confirmAppConnection(trimmedKey);
1198
+ } else {
1199
+ phone = await connectDevice(trimmedKey, { showCapabilities: !options.connectExplicit });
1023
1200
  }
1024
1201
  console.log();
1025
- console.log(` ${green2(bold2("Setup complete."))}`);
1202
+ if (keyCheck?.kind === "locked") {
1203
+ const report = describeKeyCheck(keyCheck);
1204
+ printManualSteps();
1205
+ console.log(` ${yellow(bold("Agents configured, approvals inactive."))}`);
1206
+ console.log();
1207
+ console.log(` ${report.message}`);
1208
+ for (const line of report.next) console.log(` ${dim(line)}`);
1209
+ console.log();
1210
+ emitJson(options, {
1211
+ ok: false,
1212
+ reason: "plan_inactive",
1213
+ configured: completed,
1214
+ skipped,
1215
+ manualSteps,
1216
+ failed,
1217
+ keyCheck: "locked",
1218
+ rc: rc.kind,
1219
+ exitCode: report.exitCode
1220
+ });
1221
+ process.exit(report.exitCode);
1222
+ }
1223
+ printManualSteps();
1224
+ const configuredNothing = agents.length > 0 && completed.length === 0;
1225
+ const agentsOk = failed.length === 0 && !configuredNothing;
1226
+ const reachChecked = phone !== null;
1227
+ const reachesYou = phone?.reachesYou ?? true;
1228
+ const closing = describeClosing({
1229
+ agentsOk,
1230
+ configuredNothing,
1231
+ completed,
1232
+ failed,
1233
+ reachChecked,
1234
+ reachesYou
1235
+ });
1236
+ console.log(` ${closing.ok ? green(bold(closing.headline)) : yellow(bold(closing.headline))}`);
1237
+ if (failed.length > 0) {
1238
+ console.log();
1239
+ for (const agent of completed) console.log(` ${check} ${AGENT_NAMES[agent] ?? agent}`);
1240
+ for (const item of failed) console.log(` ${yellow("!")} ${item}`);
1241
+ }
1242
+ console.log();
1243
+ for (const line of closing.next) console.log(` ${line}`);
1026
1244
  console.log();
1027
- console.log(` ${dim2("Next:")}`);
1028
- console.log(` ${dim2("1.")} Restart your agent to load the new config`);
1029
- console.log(` ${dim2("2.")} Run ${cyan2("npx @pushary/agent-hooks doctor")} to verify`);
1245
+ console.log(` ${dim("Useful:")}`);
1246
+ for (const line of renderCommandSurface()) console.log(` ${dim(line)}`);
1030
1247
  console.log();
1248
+ const exitCode = closing.exitCode;
1249
+ emitJson(options, {
1250
+ ok: closing.ok,
1251
+ configured: completed,
1252
+ skipped,
1253
+ manualSteps,
1254
+ failed,
1255
+ keyCheck: keyCheck?.kind ?? "skipped",
1256
+ reach: phone === null ? "skipped" : phone.reachesYou ? "yours" : phone.outcome?.kind ?? phone.detail ?? "unreachable",
1257
+ rc: rc.kind,
1258
+ rcPath: rc.path,
1259
+ exitCode
1260
+ });
1261
+ if (exitCode !== EXIT.OK) {
1262
+ if (configuredNothing && failed.length === 0) {
1263
+ console.log(` ${yellow("!")} Nothing was configured: none of the agents you asked for are installed here.`);
1264
+ console.log(` ${dim("Your key is saved. Install one and re-run, or pass --agents none to accept this.")}`);
1265
+ console.log();
1266
+ }
1267
+ process.exit(exitCode);
1268
+ }
1031
1269
  };
1032
1270
  main().catch((err) => {
1271
+ if (err instanceof UsageError) {
1272
+ console.log();
1273
+ console.log(` ${yellow("!")} ${err.message}`);
1274
+ console.log(` ${dim("Run")} ${cyan("pushary setup --help")} ${dim("for the full list.")}`);
1275
+ console.log();
1276
+ process.exit(EXIT.USAGE);
1277
+ }
1278
+ if (isCancellation(err)) {
1279
+ console.log();
1280
+ console.log(` ${dim("Cancelled. Nothing further was changed.")}`);
1281
+ console.log();
1282
+ process.exit(130);
1283
+ }
1033
1284
  console.log();
1034
- console.log(` ${yellow2("!")} Setup failed: ${formatError(err)}`);
1035
- console.log(` ${dim2("Run")} ${cyan2("npx @pushary/agent-hooks doctor")} ${dim2("after fixing the issue, then rerun setup.")}`);
1285
+ console.log(` ${yellow("!")} Setup failed: ${formatError(err)}`);
1286
+ console.log(` ${dim("Run")} ${cyan("npx @pushary/agent-hooks doctor")} ${dim("after fixing the issue, then rerun setup.")}`);
1036
1287
  console.log();
1037
1288
  process.exit(1);
1038
1289
  });