pidge-cli 0.7.0 → 0.8.1

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 (3) hide show
  1. package/README.md +32 -3
  2. package/bin/pidge.js +93 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,19 +11,48 @@ then gets the answer as JSON — no webhook, no polling loop to write.
11
11
  > current spec (fields, profiles, guarantees). This CLI is a thin pipe over it — any
12
12
  > new server field works without a CLI update via `--param key=value`.
13
13
 
14
- ## Setup in one command (v0.7.0 — the claim flow)
14
+ ## Setup in one command (v0.8.0 — the claim flow)
15
15
 
16
16
  ```bash
17
17
  # The human copies a setup prompt from the Pidge app (Canais → the channel) —
18
18
  # it carries a SINGLE-USE claim code (15 min TTL), never the key:
19
19
  npx pidge-cli setup --claim <code> --url https://pidge.sh
20
- # → exchanges the code for the real key, writes ~/.config/pidge/env (chmod 600)
21
- # and runs `pidge doctor`. The secret never appears on screen or in any chat.
20
+ # → exchanges the code for the real key, stores it (chmod 600), runs `pidge doctor`.
21
+ # The secret never appears on screen or in any chat (the CLI writes it).
22
22
 
23
23
  npx pidge-cli doctor # validate anytime: env source, server, key, "canal X · N devices"
24
24
  npx pidge-cli whoami # which channel does this key speak for (JSON)
25
25
  ```
26
26
 
27
+ ### Many agents on one machine — isolate them (read this)
28
+
29
+ `~/.config/pidge/env` is **one slot per machine-user**: every agent without its
30
+ own identity reads the same key, so one agent's `setup` makes another agent send
31
+ as the wrong channel (this bit us for real — a cron got hijacked). Each agent
32
+ must have its **own** identity. Cheapest correct setups, in order:
33
+
34
+ ```bash
35
+ # A. per-agent env var — the cleanest; the human sets it at the agent's launch
36
+ # (systemd unit / launcher / profile). Env var always wins over any file.
37
+ export PIDGE_TOKEN=hld_… # this agent only
38
+
39
+ # B. per-agent config file — set ONE non-secret id at launch; the CLI namespaces
40
+ # the file to ~/.config/pidge/agents/<id>/env and still writes the key for you
41
+ # (no secret in the agent's chat). setup/doctor/everything follow it.
42
+ export PIDGE_AGENT=javier
43
+ npx pidge-cli setup --claim <code>
44
+
45
+ # C. you're at YOUR terminal and want the env var hygienically from a claim:
46
+ npx pidge-cli setup --claim <code> --print # prints `export …`; writes nothing
47
+ # paste the two lines into THAT agent's launcher. NEVER run --print as an agent
48
+ # (the key would land in its context) — that's what A/B are for.
49
+ ```
50
+
51
+ The bare `~/.config/pidge/env` (no `PIDGE_AGENT`) is fine for a **single** agent;
52
+ `pidge doctor` warns loudly when you're on that shared file. Lost the local key?
53
+ Just re-claim — `POST /claim` returns the channel's **same** key, so re-running
54
+ setup restores the exact identity.
55
+
27
56
  ## Use it (no install — via npx)
28
57
 
29
58
  ```bash
package/bin/pidge.js CHANGED
@@ -41,13 +41,28 @@ const path = require('node:path');
41
41
  const os = require('node:os');
42
42
  const crypto = require('node:crypto');
43
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.
44
+ // Per-agent isolation (incident 2026-06-13): ~/.config/pidge/env is one slot
45
+ // per machine-user, so N agents sharing a HOME share an identity — one agent's
46
+ // setup hijacked another's cron. The fix is a NON-secret namespacing var the
47
+ // human sets ONCE at each agent's launch: PIDGE_AGENT=<id> the config lives
48
+ // at ~/.config/pidge/agents/<id>/env, isolated by construction. The CLI still
49
+ // WRITES the key (the agent never sees it — #57 hygiene intact), it's just
50
+ // per-agent now. No PIDGE_AGENT ⇒ the legacy shared file (single-agent only).
51
+ // (An explicit PIDGE_TOKEN env var still wins over any file — the purest
52
+ // per-agent path.)
53
+ const AGENT_ID = (process.env.PIDGE_AGENT || '').trim().replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64);
54
+ function pidgeConfigDir() {
55
+ const base = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pidge');
56
+ return AGENT_ID ? path.join(base, 'agents', AGENT_ID) : base;
57
+ }
58
+
59
+ // #57 token hygiene: when the env vars are unset, fall back to the config file
60
+ // (KEY=VALUE the CLI writes during setup, or the HUMAN writes once) so the raw
61
+ // hld_… key never rides the agent's chat/context. Explicit env vars always win;
62
+ // `export ` prefixes, quotes and #comments are tolerated.
48
63
  function configEnv() {
49
64
  try {
50
- const file = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pidge', 'env');
65
+ const file = path.join(pidgeConfigDir(), 'env');
51
66
  const out = {};
52
67
  for (let line of fs.readFileSync(file, 'utf8').split('\n')) {
53
68
  line = line.trim().replace(/^export\s+/, '');
@@ -107,14 +122,21 @@ const OPTIONS = {
107
122
  claim: { type: 'string' }, // setup --claim <single-use code>
108
123
  // #157 P2: listen keeps going after a batch (supervisor loop, one process)
109
124
  follow: { type: 'boolean' },
125
+ force: { type: 'boolean' }, // setup: overwrite a config owned by ANOTHER channel
126
+ print: { type: 'boolean' }, // setup: print export lines instead of writing a file (per-agent, human runs it)
110
127
  };
111
128
 
112
129
  const USAGE = `pidge — send an iPhone notification to a human and block until they answer.
113
130
 
114
131
  USAGE
115
132
  pidge setup --claim CODE [--url BASE] one-shot onboarding (#110): exchange the single-use
116
- code for the channel key, store it in
117
- ~/.config/pidge/env (chmod 600), run doctor
133
+ code for the channel key, store it, run doctor.
134
+ MULTI-AGENT: set PIDGE_AGENT=<id> at each agent's launch
135
+ → isolated config ~/.config/pidge/agents/<id>/env.
136
+ --print emit 'export PIDGE_TOKEN=…' instead of a file
137
+ (you run it in YOUR terminal; paste into the
138
+ agent's launcher — never run --print as an agent)
139
+ --force overwrite a shared file owned by another channel
118
140
  pidge doctor validate the setup WITHOUT exposing secrets:
119
141
  env source, server, key, "canal X · N devices"
120
142
  pidge whoami which channel does this key speak for (JSON)
@@ -188,9 +210,13 @@ OPTIONS (notify / ask)
188
210
 
189
211
  ENV
190
212
  PIDGE_URL your Pidge server (default http://localhost:3000; HERALD_URL honored)
191
- PIDGE_TOKEN your channel's bearer key (required; HERALD_TOKEN honored)
192
- with neither set, ~/.config/pidge/env (KEY=VALUE) is read the
193
- key-free path: the human writes the file once, no secret in chat
213
+ PIDGE_TOKEN your channel's bearer key (required; HERALD_TOKEN honored). Setting
214
+ this per agent at launch is the cleanest multi-agent isolation
215
+ env var always wins over any file.
216
+ PIDGE_AGENT <id> namespacing the config file to ~/.config/pidge/agents/<id>/env
217
+ so N agents on one machine never share an identity (the CLI still
218
+ writes the key — no secret in the agent's chat). Unset ⇒ the legacy
219
+ shared ~/.config/pidge/env (single-agent only).
194
220
 
195
221
  OUTPUT
196
222
  stdout is machine-readable (notify→201 JSON; ask/wait→chosen_action JSON);
@@ -241,7 +267,7 @@ function fetchT(url, opts = {}, timeoutMs = 30000) {
241
267
  // The server advertises its manifest version on every response. When it's newer
242
268
  // than what this CLI shipped knowing, nudge ONCE on stderr — the agent re-reads
243
269
  // the manifest (whats_new) and learns the new capabilities without polling.
244
- const KNOWN_MANIFEST_VERSION = 24;
270
+ const KNOWN_MANIFEST_VERSION = 26;
245
271
  let newsWarned = false;
246
272
  function checkManifestNews(res) {
247
273
  const v = parseInt(res.headers.get('x-pidge-manifest-version') || '0', 10);
@@ -647,13 +673,16 @@ const num = (val, fallback) => (val !== undefined ? parseInt(val, 10) : fallback
647
673
  // Onboarding v2 (#110): setup --claim / doctor / whoami / skill install.
648
674
  // ---------------------------------------------------------------------------
649
675
 
650
- const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pidge');
676
+ const CONFIG_DIR = pidgeConfigDir();
651
677
  const CONFIG_FILE = path.join(CONFIG_DIR, 'env');
678
+ // True when we're reading the LEGACY shared file (no PIDGE_AGENT, no env var) —
679
+ // the multi-agent footgun. doctor warns on it.
680
+ const ON_SHARED_FILE = !AGENT_ID && !process.env.PIDGE_TOKEN && !process.env.HERALD_TOKEN && !!FILE_ENV.PIDGE_TOKEN;
652
681
 
653
682
  // Where the token came from — doctor narrates it, setup respects precedence.
654
683
  function tokenSource() {
655
- if (process.env.PIDGE_TOKEN || process.env.HERALD_TOKEN) return 'env var';
656
- if (FILE_ENV.PIDGE_TOKEN) return CONFIG_FILE;
684
+ if (process.env.PIDGE_TOKEN || process.env.HERALD_TOKEN) return 'env var (per-agent)';
685
+ if (FILE_ENV.PIDGE_TOKEN) return CONFIG_FILE + (AGENT_ID ? ` (PIDGE_AGENT=${AGENT_ID})` : ' (shared)');
657
686
  return null;
658
687
  }
659
688
 
@@ -669,8 +698,11 @@ async function fetchWhoami(base = BASE, token = TOKEN) {
669
698
 
670
699
  // doctor: validate the setup WITHOUT exposing secrets. Narration on stderr,
671
700
  // a compact machine-readable line on stdout. Exit 0 healthy / 2 broken.
672
- async function runDoctor(base = BASE, token = TOKEN) {
673
- const source = token === TOKEN ? tokenSource() : CONFIG_FILE; // post-setup call passes the fresh token
701
+ async function runDoctor(base = BASE, token = TOKEN, sourceLabel = null) {
702
+ // sourceLabel is passed by setup (it knows exactly where the key went
703
+ // a per-agent file, the shared file, or NOWHERE for --print); the bare
704
+ // `doctor` command computes it from the env/file precedence.
705
+ const source = sourceLabel || (token === TOKEN ? tokenSource() : CONFIG_FILE);
674
706
  if (!token) {
675
707
  console.error('pidge doctor: NO TOKEN — set PIDGE_TOKEN, or onboard with `pidge setup --claim <code>` (the human copies the code from the Pidge app)');
676
708
  process.exit(2);
@@ -709,6 +741,8 @@ async function runDoctor(base = BASE, token = TOKEN) {
709
741
  console.error(`pidge doctor: key valid — canal "${data.channel && data.channel.name}" · ${devices} device(s)`);
710
742
  if (devices === 0)
711
743
  console.error('pidge doctor: WARNING — 0 devices: sends will reach NOBODY until the human installs/opens the Pidge app on their iPhone');
744
+ if (ON_SHARED_FILE)
745
+ console.error(`pidge doctor: WARNING — reading the SHARED file ${CONFIG_FILE}. If another agent runs on this machine, it reads the SAME key and you'll send as each other (the 2026-06-13 incident). Isolate: set PIDGE_AGENT=<id> at this agent's launch (config → ~/.config/pidge/agents/<id>/env) or give it its own PIDGE_TOKEN.`);
712
746
  console.error('pidge doctor: all good — try: pidge ask --template decision --title "Pidge funcionando?"');
713
747
  console.log(JSON.stringify({ ok: true, base_url: base, channel: data.channel, devices, manifest_version: data.manifest_version }));
714
748
  process.exit(0);
@@ -721,6 +755,28 @@ async function runSetup() {
721
755
  const code = v.claim;
722
756
  if (!code) die('pidge: usage: pidge setup --claim <code> [--url <base>] (the human copies the code from the Pidge app)', 1);
723
757
  const base = (v.url || process.env.PIDGE_URL || FILE_ENV.PIDGE_URL || 'https://pidge.sh').replace(/\/+$/, '');
758
+
759
+ // THE SHARED-CONFIG GUARD (real incident, 2026-06-13). Only the FILE path can
760
+ // collide; --print writes nothing, so skip it there. CONFIG_FILE is now
761
+ // per-agent when PIDGE_AGENT is set (no collision by construction), but on the
762
+ // legacy shared file two agents still share it — refuse to clobber a file that
763
+ // still authenticates as some channel unless --force. Checked BEFORE the
764
+ // exchange so the single-use code survives the refusal.
765
+ if (!v.print && !v.force && FILE_ENV.PIDGE_TOKEN) {
766
+ let owner = null;
767
+ try {
768
+ const { res: wres, data: wdata } = await fetchWhoami(base, FILE_ENV.PIDGE_TOKEN);
769
+ if (wres.status === 200 && wdata.channel) owner = wdata.channel.name;
770
+ else if (wres.status !== 401) owner = 'um canal (servidor não confirmou)';
771
+ // 401 ⇒ the stored key is dead — overwriting a corpse needs no --force.
772
+ } catch {
773
+ owner = 'um canal (servidor inalcançável para confirmar)';
774
+ }
775
+ if (owner) {
776
+ die(`pidge: ${CONFIG_FILE} já guarda a chave de "${owner}". Sobrescrever faria qualquer agente que lê esse arquivo enviar como o canal novo (incidente real: um cron foi sequestrado assim). O jeito certo de rodar VÁRIOS agentes na mesma máquina: dê a cada um um PIDGE_AGENT=<id> no launch (cada um ganha ~/.config/pidge/agents/<id>/env isolado), ou um PIDGE_TOKEN próprio, ou rode com --print e cole o export no launcher DESTE agente. Substituir mesmo assim? --force (o claim code continua válido — nada foi consumido).`, 2);
777
+ }
778
+ }
779
+
724
780
  let res, data = {};
725
781
  try {
726
782
  res = await fetchT(`${base}/api/v1/claim`, {
@@ -737,11 +793,30 @@ async function runSetup() {
737
793
  die(`pidge: claim failed (${res.status}): ${JSON.stringify(data)}`, 2);
738
794
 
739
795
  const finalBase = (data.base_url || base).replace(/\/+$/, '');
796
+ const channelName = data.channel && data.channel.name;
797
+
798
+ // --print: the pure per-agent path — emit the export lines (the HUMAN runs
799
+ // this in THEIR terminal and pastes them into the agent's launcher). Stores
800
+ // nothing; the key shows on screen, so DON'T let an agent run --print (the
801
+ // key would land in its context — that's what the file path is for). stdout
802
+ // is eval-able; the guidance goes to stderr.
803
+ if (v.print) {
804
+ console.log(`export PIDGE_URL=${finalBase}`);
805
+ console.log(`export PIDGE_TOKEN=${data.key}`);
806
+ console.error(`pidge: canal "${channelName}" — modo POR-AGENTE (nada gravado em disco). Cole as duas linhas no ambiente de lançamento DESTE agente (systemd/launcher/cron/profile). Cada agente tem a SUA chave; perdeu, é só pegar outro código no app e re-rodar (a chave do canal é a MESMA). NÃO rode --print de dentro de um agente — a chave apareceria no contexto dele.`);
807
+ await runDoctor(finalBase, data.key, 'fresh claim (per-agent env — not stored on disk)');
808
+ return;
809
+ }
810
+
811
+ // File path (default): the CLI writes the key — the agent never sees it
812
+ // (#57). Per-agent when PIDGE_AGENT is set; otherwise the legacy shared file.
740
813
  fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
741
814
  fs.writeFileSync(CONFIG_FILE, `PIDGE_URL=${finalBase}\nPIDGE_TOKEN=${data.key}\n`, { mode: 0o600 });
742
815
  try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* mode set on create */ }
743
- console.error(`pidge: configured for canal "${data.channel && data.channel.name}" — key stored in ${CONFIG_FILE} (chmod 600, never displayed)`);
744
- await runDoctor(finalBase, data.key);
816
+ console.error(`pidge: canal "${channelName}" configurado chave em ${CONFIG_FILE} (chmod 600, nunca exibida)`);
817
+ if (!AGENT_ID)
818
+ console.error('pidge: este é o arquivo COMPARTILHADO (single-agent). Vai rodar 2+ agentes nesta máquina? Dê a cada um PIDGE_AGENT=<id> no launch (arquivo isolado por agente) — senão eles enviam como o mesmo canal.');
819
+ await runDoctor(finalBase, data.key, CONFIG_FILE);
745
820
  }
746
821
 
747
822
  // skill install (#110e): persistent Pidge knowledge for Claude Code agents —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pidge-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Send rich, actionable iPhone notifications to a human and block until they answer. Built for AI agents.",
5
5
  "keywords": [
6
6
  "pidge",