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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/bin/pidge.js +442 -0
- 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
|
+
}
|