pidge-cli 0.4.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/bin/pidge.js +442 -0
  4. package/package.json +23 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thiago Correa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # pidge
2
+
3
+ Send rich, actionable **iPhone notifications to a human and block until they answer** —
4
+ built for AI agents (Hermes, Claude Code, or any agent with a shell).
5
+
6
+ It's a thin wrapper over the [Pidge](https://pidge.sh) API. The real value is
7
+ `ask`/`wait`: the agent fires a notification and **blocks until the human responds**,
8
+ then gets the answer as JSON — no webhook, no polling loop to write.
9
+
10
+ > **The contract lives server-side.** `GET $PIDGE_URL/api/v1/manifest` is the always-
11
+ > current spec (fields, profiles, guarantees). This CLI is a thin pipe over it — any
12
+ > new server field works without a CLI update via `--param key=value`.
13
+
14
+ ## Use it (no install — via npx)
15
+
16
+ ```bash
17
+ export PIDGE_URL=https://pidge.sh # your Pidge server
18
+ export PIDGE_TOKEN=hld_xxx # your channel's bearer key
19
+ # (or skip the exports: the CLI reads ~/.config/pidge/env — KEY=VALUE — so the
20
+ # key never has to appear in an agent's chat; explicit env vars win)
21
+
22
+ # Send AND wait for the answer (the one an agent wants):
23
+ npx pidge-cli ask \
24
+ --title "Aprovar deploy?" --actions yes,no,reply --timeout 600
25
+
26
+ # Urgent — escalates to an AlarmKit alarm if the human doesn't answer in minutes:
27
+ npx pidge-cli ask \
28
+ --title "Posso rodar a migration?" --profile escalating --actions yes,no
29
+
30
+ # A thing with a known time — push at T−lead + a lock-screen countdown to the event:
31
+ npx pidge-cli notify \
32
+ --title "Reunião com o time" --profile event --event-at "2026-06-10T15:00:00"
33
+
34
+ # A chart you generated — uploaded for you, shown on the banner + feed:
35
+ npx pidge-cli notify --title "Gráfico pronto" --image ./chart.png
36
+
37
+ # A real artifact — the human previews it on the phone, shares it, saves to Files:
38
+ npx pidge-cli notify --title "Relatório" --file ./relatorio.xlsx
39
+ ```
40
+
41
+ `ask` prints the chosen action as JSON to **stdout** and exits `0`:
42
+
43
+ ```json
44
+ { "kind": "acted", "action_id": "yes", "label": "Sim", "text": null,
45
+ "at": "2026-06-08T18:19:51Z", "snooze_until": null }
46
+ ```
47
+
48
+ ## Commands
49
+
50
+ | Command | What it does |
51
+ |---|---|
52
+ | `ask` | Send a notification **and block** until the human answers; prints the chosen action JSON. The default for agents. |
53
+ | `notify` | Send only. Prints the raw 201 JSON; the `correlation_id` + warnings go to stderr. |
54
+ | `wait <correlation_id>` | Block on an already-sent notification until it's answered. |
55
+ | `cancel <correlation_id>` | Cancel a **still-scheduled** notification before it fires (idempotent; 409 once it reached the phone). |
56
+
57
+ ## Options (for `notify` / `ask`)
58
+
59
+ ```
60
+ --title TEXT (required) the headline
61
+ --body TEXT the message shown on the banner
62
+ --body-markdown MD rich body for the tap-through detail screen
63
+ --subtitle TEXT
64
+ --profile ID delivery profile — the HUMAN owns what each one does:
65
+ default · event (needs --event-at; countdown Live Activity) ·
66
+ escalating (alarm if unanswered minutes after delivery) ·
67
+ the user's custom profiles. See the manifest's `profiles`.
68
+ --event-at ISO8601 WHEN the thing happens (a FACT; required by profile event)
69
+ --lead-minutes N notify/start the countdown N min before event_at (5–240)
70
+ --urgency LEVEL normal | persistent | alarm (low-level — prefer --profile)
71
+ --image PATH_OR_URL image on the banner + feed: a local path is uploaded for you
72
+ (your machine has no public URL); an https URL is sent as-is
73
+ --file PATH a real artifact (xlsx, pdf, csv…) the human previews, shares
74
+ and saves on the phone; uploaded automatically (≤25 MB)
75
+ --url URL deep link the app opens when the user taps (PR, dashboard, log)
76
+ --copy TEXT value offered as tap-to-copy on the detail (code, token)
77
+ --actions LIST comma list: yes,no,approve,reject,accept,decline,later,
78
+ done,snooze,reschedule,reply,mute
79
+ --custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]"
80
+ (repeatable — your own buttons)
81
+ --deliver-at ISO8601 schedule for later
82
+ --reply-to URL also POST the answer to your webhook (HMAC-signed)
83
+ --correlation-id ID idempotency + routing key (auto-generated if omitted)
84
+ --collapse-key KEY replace/update a prior notification
85
+ --param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
86
+ fields work day-one, no CLI update needed
87
+ --timeout SECONDS ask: default 600 · wait: default 300
88
+ --interval SECONDS FALLBACK poll cadence (default 30) — normally unused: the
89
+ server long-polls each GET (?wait=55), answers are ~instant
90
+ ```
91
+
92
+ ## Contract (important for agents)
93
+
94
+ - **`ask` prints `correlation_id=<cid>` as its FIRST stderr line** (minted client-side
95
+ when you don't pass one) — a killed `ask` always leaves the handle behind, so you
96
+ can `pidge wait <cid>` instead of re-sending.
97
+ - **stdout is always machine-readable.** `notify` → the raw 201 JSON; `ask`/`wait` →
98
+ the `chosen_action` JSON. Everything human (warnings, the correlation_id, snooze
99
+ notices, armed-escalation and policy-degrade narration) goes to **stderr**.
100
+ - **Exit codes:** `0` answered · `3` timed out (= *no answer yet*, NOT a failure —
101
+ back off and retry later) · `2` error · `1` usage.
102
+ - **Responses are one-and-done.** Every answer closes the notification EXCEPT a
103
+ **snooze** (or a reschedule that set a new time), which re-fires later. `ask`/`wait`
104
+ keep polling through a snooze and print `snooze_until` so you can schedule a re-check.
105
+ - **Profiles degrade, never reject.** An over-ceiling profile is delivered at the
106
+ channel's allowed level — read `degraded`/`degrade_reason` in the 201 (narrated on
107
+ stderr). That's the human's policy working; don't retry harder.
108
+ - **`ask --profile tracking` is refused** — tracking is Live-Activity-only and never
109
+ produces an answer.
110
+ - A genuine follow-up question is a **new** notification, never a second answer on
111
+ the same one.
112
+
113
+ ENV: `PIDGE_URL` / `PIDGE_TOKEN` (the old `HERALD_URL` / `HERALD_TOKEN` still work);
114
+ with neither set, `~/.config/pidge/env` (KEY=VALUE) is read — the key-free path.
115
+
116
+ Full machine-readable spec: `GET $PIDGE_URL/api/v1/manifest` (Bearer auth).
117
+
118
+ ## License
119
+
120
+ MIT
package/bin/pidge.js ADDED
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // pidge — CLI so an agent (Hermes, or a running Claude Code) can send a rich
5
+ // iPhone notification AND block until the human answers (polling — the primary
6
+ // read path for terminal/CLI use, where there's no webhook to receive a reply).
7
+ //
8
+ // export PIDGE_URL=https://pidge.sh # default http://localhost:3000
9
+ // export PIDGE_TOKEN=hld_xxx # the channel's bearer key
10
+ // (HERALD_URL / HERALD_TOKEN are honored as a fallback; with no env vars set,
11
+ // ~/.config/pidge/env — KEY=VALUE — is read instead, so the key can live
12
+ // OUTSIDE the agent's chat/context entirely, #57)
13
+ //
14
+ // # send AND block until the human answers, then print the chosen action as JSON
15
+ // pidge ask --title "Aprovar deploy?" --actions yes,no,reply --timeout 600
16
+ //
17
+ // # urgent: escalates to an AlarmKit alarm if unanswered (see the manifest's profiles)
18
+ // pidge ask --title "Posso rodar a migration?" --profile escalating --actions yes,no
19
+ //
20
+ // # a thing with a known time: push at T−lead + a lock-screen countdown to the event
21
+ // pidge notify --title "Reunião com o time" --profile event --event-at "2026-06-10T15:00:00"
22
+ //
23
+ // # block on an already-sent notification (by correlation_id)
24
+ // pidge wait order-7 --timeout 300
25
+ //
26
+ // # cancel a still-scheduled notification before it fires (#56)
27
+ // pidge cancel med-ozempic-qui
28
+ //
29
+ // stdout is ALWAYS machine-readable: `notify` prints the raw 201 JSON; `ask`/`wait`
30
+ // print the chosen_action JSON. Everything human (warnings, the correlation_id,
31
+ // snooze notices) goes to stderr. Exit codes: 0 = responded, 3 = timed out (= "no
32
+ // answer yet", NOT a failure — back off and retry later), 2 = error, 1 = usage.
33
+ //
34
+ // DESIGN: this CLI is a thin pipe — the SERVER's manifest (GET /api/v1/manifest)
35
+ // is the contract, and validation lives server-side (422s are self-describing).
36
+ // New /notify fields work without a CLI release via --param key=value.
37
+
38
+ const { parseArgs } = require('node:util');
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+ const os = require('node:os');
42
+ const crypto = require('node:crypto');
43
+
44
+ // #57 token hygiene: when the env vars are unset, fall back to
45
+ // ~/.config/pidge/env (KEY=VALUE lines the HUMAN writes once in THEIR terminal)
46
+ // so the raw hld_… key never has to ride the agent's chat/context. Explicit env
47
+ // vars always win; `export ` prefixes, quotes and #comments are tolerated.
48
+ function configEnv() {
49
+ try {
50
+ const file = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pidge', 'env');
51
+ const out = {};
52
+ for (let line of fs.readFileSync(file, 'utf8').split('\n')) {
53
+ line = line.trim().replace(/^export\s+/, '');
54
+ if (!line || line.startsWith('#')) continue;
55
+ const i = line.indexOf('=');
56
+ if (i < 1) continue;
57
+ const value = line.slice(i + 1).replace(/^["']|["']$/g, '');
58
+ if (value) out[line.slice(0, i)] = value;
59
+ }
60
+ return out;
61
+ } catch { return {}; }
62
+ }
63
+ const FILE_ENV = configEnv();
64
+
65
+ const BASE = process.env.PIDGE_URL || process.env.HERALD_URL || FILE_ENV.PIDGE_URL || 'http://localhost:3000';
66
+ const TOKEN = process.env.PIDGE_TOKEN || process.env.HERALD_TOKEN || FILE_ENV.PIDGE_TOKEN;
67
+
68
+ function die(msg, code = 1) { console.error(msg); process.exit(code); }
69
+ if (!TOKEN) die('pidge: set PIDGE_TOKEN (env var, or put PIDGE_TOKEN=… in ~/.config/pidge/env)');
70
+
71
+ const OPTIONS = {
72
+ help: { type: 'boolean', short: 'h' },
73
+ title: { type: 'string' },
74
+ body: { type: 'string' },
75
+ 'body-markdown': { type: 'string' },
76
+ subtitle: { type: 'string' },
77
+ template: { type: 'string' }, // content/action pattern (manifest `templates`)
78
+ profile: { type: 'string' }, // delivery profile id (manifest `profiles`)
79
+ 'event-at': { type: 'string' }, // WHEN the thing happens (profile event)
80
+ 'lead-minutes': { type: 'string' }, // notify/countdown lead before event_at
81
+ urgency: { type: 'string' }, // normal | persistent | alarm (low-level — prefer --profile)
82
+ image: { type: 'string' }, // banner+feed image: local path → uploaded; URL → as-is
83
+ file: { type: 'string' }, // real artifact (xlsx/pdf/csv…): local path → uploaded
84
+ url: { type: 'string' }, // deep link the app opens on tap (#45)
85
+ copy: { type: 'string' }, // tap-to-copy value on the detail (#45)
86
+ actions: { type: 'string' }, // comma list from the catalog
87
+ 'custom-action': { type: 'string', multiple: true }, // id:label[:destructive][:confirm][:biometric][:terminal]
88
+ 'deliver-at': { type: 'string' },
89
+ 'reply-to': { type: 'string' },
90
+ 'correlation-id': { type: 'string' },
91
+ 'collapse-key': { type: 'string' },
92
+ param: { type: 'string', multiple: true }, // key=value escape hatch → raw /notify field
93
+ timeout: { type: 'string' },
94
+ interval: { type: 'string' },
95
+ };
96
+
97
+ const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
98
+
99
+ USAGE
100
+ pidge ask [options] send AND wait for the answer (prints chosen_action JSON)
101
+ pidge notify [options] send only (prints the 201 JSON)
102
+ pidge wait <correlation_id> [options] block on an already-sent notification
103
+ pidge cancel <correlation_id> cancel a still-scheduled notification (#56)
104
+ pidge --help
105
+
106
+ OPTIONS (notify / ask)
107
+ --title TEXT (required) the headline
108
+ --body TEXT message shown on the banner
109
+ --body-markdown MD rich body for the tap-through detail screen
110
+ --subtitle TEXT
111
+ --template ID content/action pattern — WHAT you're asking: context (FYI,
112
+ no buttons) · decision (yes/no/reply) · approval · reminder ·
113
+ nudge · sensitive (gated, Face ID). Composes with --profile.
114
+ --profile ID delivery profile (the HUMAN owns what it does): default ·
115
+ event (needs --event-at; countdown Live Activity) ·
116
+ escalating (alarm if unanswered minutes after delivery) ·
117
+ the user's custom profiles. See the manifest's \`profiles\`.
118
+ --event-at ISO8601 WHEN the thing happens (a FACT; required by profile event)
119
+ --lead-minutes N notify/start countdown N min before event_at (5–240)
120
+ --urgency LEVEL normal | persistent | alarm (low-level — prefer --profile)
121
+ --image PATH_OR_URL image on the banner + feed: a local path is uploaded for
122
+ you (your machine has no public URL); an https URL is sent as-is
123
+ --file PATH a real artifact (xlsx, pdf, csv…) the human previews,
124
+ shares and saves on the phone; uploaded automatically (≤25 MB)
125
+ --url URL deep link the app opens when the user taps (PR, dashboard, log)
126
+ --copy TEXT value offered as tap-to-copy on the detail (code, token)
127
+ --actions LIST comma list: yes,no,approve,reject,accept,decline,later,
128
+ done,snooze,reschedule,reply,mute
129
+ --custom-action SPEC "id:label[:destructive][:confirm][:biometric][:terminal]" (repeatable)
130
+ --deliver-at ISO8601 schedule for later
131
+ --reply-to URL also POST the answer to your webhook (HMAC-signed)
132
+ --correlation-id ID idempotency + routing key (auto-generated if omitted)
133
+ --collapse-key KEY replace/update a prior notification
134
+ --param KEY=VALUE pass ANY raw /notify field (repeatable) — future server
135
+ fields work without a CLI update; the manifest is the contract
136
+ --timeout SECONDS ask: 600 · wait: 300
137
+ --interval SECONDS FALLBACK poll cadence (default 30) — normally unused: the
138
+ server long-polls each GET (?wait=55), answers are ~instant
139
+
140
+ ENV
141
+ PIDGE_URL your Pidge server (default http://localhost:3000; HERALD_URL honored)
142
+ PIDGE_TOKEN your channel's bearer key (required; HERALD_TOKEN honored)
143
+ with neither set, ~/.config/pidge/env (KEY=VALUE) is read — the
144
+ key-free path: the human writes the file once, no secret in chat
145
+
146
+ OUTPUT
147
+ stdout is machine-readable (notify→201 JSON; ask/wait→chosen_action JSON);
148
+ human notices go to stderr. Exit: 0 answered · 3 timed out (no answer yet,
149
+ not a failure) · 2 error · 1 usage.
150
+
151
+ Responses are one-and-done EXCEPT snooze/reschedule (they re-fire); ask/wait keep
152
+ polling through a snooze and print snooze_until. Follow-up = a NEW notification.
153
+ An over-ceiling profile is delivered DEGRADED, never rejected — read the 201's
154
+ degraded/degrade_reason (narrated on stderr). profile "tracking" is Live-Activity-
155
+ only: it never produces an answer, so \`ask\` refuses it.
156
+
157
+ Full spec (the contract — always current): GET $PIDGE_URL/api/v1/manifest`;
158
+
159
+ let parsed;
160
+ try {
161
+ parsed = parseArgs({ options: OPTIONS, allowPositionals: true });
162
+ } catch (e) {
163
+ die(`pidge: ${e.message}\n\n${USAGE}`, 1);
164
+ }
165
+ const v = parsed.values;
166
+ const command = parsed.positionals[0];
167
+
168
+ // `pidge --help` / `-h` / `help` → full help on stdout, exit 0. No command → stderr, exit 1.
169
+ if (v.help || command === 'help') { console.log(USAGE); process.exit(0); }
170
+ if (!command) { console.error(USAGE); process.exit(1); }
171
+
172
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
173
+ const headers = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
174
+
175
+ // The server advertises its manifest version on every response. When it's newer
176
+ // than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
177
+ // the manifest (whats_new) and learns the new capabilities without polling.
178
+ const KNOWN_MANIFEST_VERSION = 7;
179
+ let newsWarned = false;
180
+ function checkManifestNews(res) {
181
+ const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
182
+ if (v > KNOWN_MANIFEST_VERSION && !newsWarned) {
183
+ newsWarned = true;
184
+ console.error(`pidge: the server has NEW capabilities (manifest v${v}; this CLI knows v${KNOWN_MANIFEST_VERSION}) — re-read GET $PIDGE_URL/api/v1/manifest (see whats_new) and consider updating the CLI`);
185
+ }
186
+ }
187
+
188
+ // Map CLI flags → the /notify JSON body, including only what was provided.
189
+ function buildBody() {
190
+ if (!v.title) die('pidge: --title is required', 1);
191
+ const body = { title: v.title };
192
+ if (v.body !== undefined) body.body = v.body;
193
+ if (v['body-markdown'] !== undefined) body.body_markdown = v['body-markdown'];
194
+ if (v.subtitle !== undefined) body.subtitle = v.subtitle;
195
+ if (v.template !== undefined) body.template = v.template;
196
+ if (v.profile !== undefined) body.profile = v.profile;
197
+ if (v['event-at'] !== undefined) body.event_at = v['event-at'];
198
+ if (v['lead-minutes'] !== undefined) body.lead_minutes = parseInt(v['lead-minutes'], 10);
199
+ if (v.urgency !== undefined) body.urgency = v.urgency;
200
+ if (v.url !== undefined) body.url = v.url;
201
+ if (v.copy !== undefined) body.copy = v.copy;
202
+ if (v['deliver-at'] !== undefined) body.deliver_at = v['deliver-at'];
203
+ if (v['reply-to'] !== undefined) body.reply_to = v['reply-to'];
204
+ if (v['correlation-id'] !== undefined) body.correlation_id = v['correlation-id'];
205
+ if (v['collapse-key'] !== undefined) body.collapse_key = v['collapse-key'];
206
+ if (v.actions !== undefined) body.actions = v.actions.split(',').filter(Boolean);
207
+
208
+ const customs = v['custom-action'] || [];
209
+ if (customs.length) {
210
+ body.custom_actions = customs.map((spec) => {
211
+ const [id, label, ...flags] = spec.split(':');
212
+ const ca = { id, label };
213
+ if (flags.includes('destructive')) ca.style = 'destructive';
214
+ if (flags.includes('confirm')) ca.confirm = true;
215
+ if (flags.includes('biometric')) ca.biometric = true;
216
+ if (flags.includes('terminal')) ca.terminal = true;
217
+ return ca;
218
+ });
219
+ }
220
+
221
+ // Escape hatch: any raw /notify field, so a NEW server field documented in the
222
+ // manifest works the day it ships — no CLI release needed. JSON values parse
223
+ // (numbers/bools/objects); anything else passes as a string.
224
+ for (const pair of v.param || []) {
225
+ const eq = pair.indexOf('=');
226
+ if (eq < 1) die(`pidge: --param expects KEY=VALUE, got ${JSON.stringify(pair)}`, 1);
227
+ const key = pair.slice(0, eq);
228
+ const raw = pair.slice(eq + 1);
229
+ let value = raw;
230
+ try { value = JSON.parse(raw); } catch { /* keep the string */ }
231
+ body[key] = value;
232
+ }
233
+ return body;
234
+ }
235
+
236
+ const MIME = {
237
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
238
+ '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
239
+ '.pdf': 'application/pdf', '.csv': 'text/csv', '.txt': 'text/plain',
240
+ '.md': 'text/markdown', '.json': 'application/json', '.zip': 'application/zip',
241
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
242
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
243
+ };
244
+ const guessMime = (p) => MIME[path.extname(p).toLowerCase()] || 'application/octet-stream';
245
+
246
+ // Multipart upload of a local file to POST /api/v1/uploads → the opaque `ref`
247
+ // /notify accepts as `image`/`file`. This is how a LOCALLY-generated artifact
248
+ // reaches the phone: the agent's machine has no public URL and the push payload
249
+ // is far too small to carry a file.
250
+ async function uploadFile(filePath) {
251
+ const fd = new FormData();
252
+ fd.append('file', new Blob([fs.readFileSync(filePath)], { type: guessMime(filePath) }),
253
+ path.basename(filePath));
254
+ let res, raw;
255
+ try {
256
+ res = await fetch(`${BASE}/api/v1/uploads`, {
257
+ method: 'POST', headers: { authorization: `Bearer ${TOKEN}` }, body: fd,
258
+ });
259
+ raw = await res.text();
260
+ } catch (e) {
261
+ die(`pidge: upload failed (network): ${e.message}`, 2);
262
+ }
263
+ if (!(res.status >= 200 && res.status < 300)) die(`pidge: upload failed (${res.status}): ${raw}`, 2);
264
+ let ref;
265
+ try { ref = JSON.parse(raw).ref; } catch { /* fall through */ }
266
+ if (!ref) die('pidge: upload returned no ref', 2);
267
+ return ref;
268
+ }
269
+
270
+ // --image / --file: an existing local path is uploaded and swapped for its ref;
271
+ // anything else (an https URL on --image, or an already-minted ref) passes through
272
+ // untouched — the server 422s self-describingly on an invalid value.
273
+ async function resolveMedia(body) {
274
+ for (const key of ['image', 'file']) {
275
+ if (v[key] === undefined) continue;
276
+ if (fs.existsSync(v[key])) {
277
+ body[key] = await uploadFile(v[key]);
278
+ } else if (key === 'file' && (/^[./~]/.test(v[key]) || v[key].includes('/'))) {
279
+ // --file is PATH-only (no URL form) — fail fast on a typo'd path; the remote
280
+ // 422 ("ref invalid — re-upload") would misdirect the agent's self-heal.
281
+ die(`pidge: --file: no such file: ${v[key]}`, 1);
282
+ } else {
283
+ body[key] = v[key];
284
+ }
285
+ }
286
+ }
287
+
288
+ // POST /notify. Returns { ok, info, raw }. Emits to STDERR what an agent most
289
+ // needs to KNOW (0 devices / no banner buttons / an armed alarm / a policy
290
+ // degrade), so stdout stays free for machine output.
291
+ async function doNotify() {
292
+ const payload = buildBody();
293
+ await resolveMedia(payload);
294
+ let res, raw;
295
+ try {
296
+ res = await fetch(`${BASE}/api/v1/notify`, {
297
+ method: 'POST', headers, body: JSON.stringify(payload),
298
+ });
299
+ raw = await res.text();
300
+ } catch (e) {
301
+ die(`pidge: send failed (network): ${e.message}`, 2);
302
+ }
303
+ checkManifestNews(res);
304
+ const ok = res.status >= 200 && res.status < 300;
305
+ let info = {};
306
+ try { info = JSON.parse(raw); } catch { /* leave {} */ }
307
+ if (ok) {
308
+ // #56: the same correlation_id while still scheduled EDITS in place.
309
+ if (info.updated)
310
+ console.error('pidge: updated scheduled notification (same correlation_id, nothing fires twice)');
311
+ if (info.registered_devices === 0)
312
+ console.error('pidge: 0 registered devices — nobody will receive this');
313
+ if (info.render_mode === 'detail_only')
314
+ console.error('pidge: render_mode=detail_only — the banner shows NO buttons; the user must open the app to act (use a banner-eligible action shape if you want quick taps)');
315
+ const esc = info.escalation;
316
+ if (esc && (esc.state === 'pending' || esc.state === 'armed')) {
317
+ const when = esc.after_minutes != null ? `${esc.after_minutes} min after delivery` : 'on delivery';
318
+ console.error(`pidge: ESCALATES TO ALARM if unanswered ${when} (answering/snoozing defuses it)`);
319
+ }
320
+ if (info.degraded)
321
+ console.error(`pidge: DEGRADED by channel policy — ${info.degrade_reason} (delivered anyway, quieter; the human's setting, don't retry harder)`);
322
+ } else {
323
+ console.error(`pidge: send failed (${res.status}): ${raw}`);
324
+ }
325
+ return { ok, info, raw };
326
+ }
327
+
328
+ // Poll GET /notifications/:cid until a TERMINAL answer, print chosen_action JSON to
329
+ // stdout, exit 0. A snooze (snooze / reschedule-to-a-time) is non-terminal — it
330
+ // re-fires — so keep waiting through it. Exits 3 on timeout.
331
+ // Long-poll (#45): each GET carries ?wait=N (≤55 s) and the SERVER holds it until
332
+ // the user acts — answer latency ~instant, ~1 request/min. --interval is only the
333
+ // fallback pace against an old server that ignores `wait` (returns immediately).
334
+ async function doWait(cid, { timeout, interval }) {
335
+ const deadline = Date.now() + timeout * 1000;
336
+ let firedNotice = false;
337
+ for (;;) {
338
+ const waitS = Math.max(0, Math.min(55, Math.ceil((deadline - Date.now()) / 1000)));
339
+ const url = `${BASE}/api/v1/notifications/${encodeURIComponent(cid)}?wait=${waitS}`;
340
+ const askedAt = Date.now();
341
+ try {
342
+ const res = await fetch(url, { headers });
343
+ checkManifestNews(res);
344
+ if (res.status === 200) {
345
+ const data = await res.json().catch(() => ({}));
346
+ if (data.responded) {
347
+ const chosen = data.chosen_action || {};
348
+ if (chosen.kind === 'snoozed') {
349
+ console.error(`pidge: snoozed until ${chosen.snooze_until || chosen.at} — re-fires then, still waiting`);
350
+ } else {
351
+ console.log(JSON.stringify(chosen, null, 2));
352
+ process.exit(0);
353
+ }
354
+ } else if (!firedNotice && data.escalation && data.escalation.state === 'fired') {
355
+ firedNotice = true;
356
+ console.error('pidge: the escalation alarm FIRED and there is still no answer — the human may have stopped the alarm on-device (that is not reported back); keep waiting or back off');
357
+ }
358
+ } else if (res.status === 404) {
359
+ console.error(`pidge: no notification for correlation_id=${cid}`);
360
+ // keep polling — the agent may call wait/ask before the send round-trips
361
+ } else {
362
+ console.error(`pidge: poll error ${res.status}`);
363
+ }
364
+ } catch (e) {
365
+ console.error(`pidge: poll error (network): ${e.message}`);
366
+ }
367
+
368
+ if (Date.now() >= deadline) {
369
+ console.error(`pidge: timed out after ${timeout}s waiting on ${cid} (= 'no answer yet', not a failure)`);
370
+ process.exit(3);
371
+ }
372
+ // A server WITH long-poll just held us for waitS — loop right back. One that
373
+ // ignored `wait` (or a network error) returned fast: pace with --interval.
374
+ if (Date.now() - askedAt < 2000) await sleep(interval * 1000);
375
+ }
376
+ }
377
+
378
+ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback);
379
+
380
+ (async () => {
381
+ switch (command) {
382
+ case 'notify': {
383
+ const { ok, info, raw } = await doNotify();
384
+ console.log(raw);
385
+ if (ok && info.correlation_id)
386
+ console.error(`pidge: correlation_id=${info.correlation_id} (use: pidge wait ${info.correlation_id})`);
387
+ process.exit(ok ? 0 : 2);
388
+ break;
389
+ }
390
+ case 'ask': {
391
+ // Send, then block on the answer in one shot. stdout = ONLY chosen_action JSON.
392
+ // tracking is Live-Activity-only: it NEVER produces a chosen_action, so an ask
393
+ // would block the full timeout believing the human is deciding.
394
+ if (v.profile === 'tracking')
395
+ die('pidge: `ask --profile tracking` makes no sense — tracking never produces an answer (use the live_activities API; need a decision? send a real profile)', 1);
396
+ if (!v.title) die('pidge: --title is required', 1);
397
+ // The cid is minted CLIENT-side when not given, and printed as the FIRST
398
+ // stderr line (greppable) — a killed/crashed ask always leaves the handle
399
+ // behind, so the agent can `pidge wait <cid>` instead of re-sending.
400
+ const cid = v['correlation-id'] || crypto.randomUUID();
401
+ v['correlation-id'] = cid;
402
+ console.error(`pidge: correlation_id=${cid}`);
403
+ const { ok, info } = await doNotify();
404
+ if (!ok) process.exit(2);
405
+ console.error(`pidge: sent (${info.registered_devices} device(s)) — waiting on ${cid}`);
406
+ await doWait(cid, { timeout: num(v.timeout, 600), interval: num(v.interval, 30) });
407
+ break;
408
+ }
409
+ case 'wait': {
410
+ const cid = parsed.positionals[1];
411
+ if (!cid) die('pidge: usage: pidge wait <correlation_id> [--timeout N] [--interval N]', 1);
412
+ await doWait(cid, { timeout: num(v.timeout, 300), interval: num(v.interval, 30) });
413
+ break;
414
+ }
415
+ case 'cancel': {
416
+ // #56: withdraw a still-scheduled notification (also kills a snooze re-fire).
417
+ // Exit 0 cancelled (idempotent) · 2 otherwise (404 unknown, 409 too late).
418
+ const cid = parsed.positionals[1];
419
+ if (!cid) die('pidge: usage: pidge cancel <correlation_id>', 1);
420
+ let res, raw;
421
+ try {
422
+ res = await fetch(`${BASE}/api/v1/notifications/${encodeURIComponent(cid)}`, {
423
+ method: 'DELETE', headers,
424
+ });
425
+ raw = await res.text();
426
+ } catch (e) {
427
+ die(`pidge: cancel failed (network): ${e.message}`, 2);
428
+ }
429
+ checkManifestNews(res);
430
+ console.log(raw);
431
+ if (res.status >= 200 && res.status < 300) {
432
+ console.error(`pidge: cancelled ${cid} — nothing will fire`);
433
+ process.exit(0);
434
+ }
435
+ console.error(`pidge: cancel failed (${res.status}) — ${res.status === 409 ? 'too late, it already reached the phone' : 'unknown correlation_id?'}`);
436
+ process.exit(2);
437
+ break;
438
+ }
439
+ default:
440
+ die(USAGE, 1);
441
+ }
442
+ })();
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "pidge-cli",
3
+ "version": "0.4.0",
4
+ "description": "Send rich, actionable iPhone notifications to a human and block until they answer. Built for AI agents.",
5
+ "keywords": ["pidge", "notifications", "agent", "cli", "push", "ai"],
6
+ "license": "MIT",
7
+ "type": "commonjs",
8
+ "bin": {
9
+ "pidge": "bin/pidge.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/thiagoc7/pidge-cli.git"
22
+ }
23
+ }