@avgvstvs96/pigeon 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bassim Shahidy
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 CHANGED
@@ -1,3 +1,280 @@
1
- # Temporary Holding Version
1
+ # pigeon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your messages, from the terminal. Read, search, watch and send iMessage, with
4
+ filters that decide what anything reading through pigeon gets to see.
5
+
6
+ Built for people and for agents: plain-text transcripts by default, JSON lines
7
+ with `--json`. Runs on Bun.
8
+
9
+ ```
10
+ $ pigeon read
11
+ ── Mom · +12125550101
12
+ Oct 11 13:24 Mom: are you still coming sunday?
13
+ Oct 11 13:34 me: yes! bringing dessert
14
+ ── Riley Quinn · +12125550102
15
+ Oct 12 14:24 Riley Quinn: the wifi password is [redacted]
16
+ Oct 12 14:34 me: thank you!!
17
+ ── Book club · chat204
18
+ Oct 13 05:24 Sam Okafor: chapter 9 for thursday?
19
+ Oct 13 05:39 me: on it [attachment: IMG_2041.heic]
20
+ ```
21
+
22
+ - **Read and search** every chat, with names from your Contacts
23
+ - **Watch** new messages as they arrive, ready to pipe into a script or an agent
24
+ - **Send** text and files through Messages
25
+ - **Redact** a chat, a day, a sender or a string, on every command, with no way around it from the CLI
26
+ - **Make it yours** in one `config.ts`: [filters](#filters) and [sources](#sources) for Telegram, WhatsApp, anything, built on the same API as iMessage
27
+
28
+ ## Install
29
+
30
+ pigeon needs [Bun](https://bun.sh) and macOS for iMessage. On Linux it can
31
+ still read a `chat.db` copied from a Mac.
32
+
33
+ ```bash
34
+ bun add -g github:AVGVSTVS96/pigeon
35
+ pigeon --help
36
+ ```
37
+
38
+ Give your terminal **Full Disk Access** (System Settings › Privacy & Security)
39
+ so it can read `~/Library/Messages/chat.db`. The first `pigeon send` asks once
40
+ for permission to control Messages.
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ pigeon chats # chats, most recent first
46
+ pigeon chats book # chats whose name contains "book"
47
+ pigeon read mom # the last 30 messages with Mom
48
+ pigeon read # the last 30 messages anywhere
49
+ pigeon read -s 1d # everything from the last 24 hours
50
+ pigeon read -s yesterday -u today # all of yesterday
51
+ pigeon search dinner sunday # messages with every word, newest 30
52
+ pigeon search pie -c mom # ...in one chat
53
+ pigeon watch # new messages, live
54
+ pigeon watch --json | jq . # ...as JSON lines
55
+ pigeon send mom "landed!" # send a message (to an id or a chat's full name)
56
+ pigeon send mom -f pie.jpg # send a file
57
+ echo "on my way" | pigeon send +12125550101
58
+ pigeon config # which config loaded, and how many filters
59
+ ```
60
+
61
+ A chat is its id or its name. For a 1:1 iMessage chat the id is the other
62
+ person's phone number or email, written any way you like (`+1 212 555 0101`,
63
+ `2125550101`). Group chats have ids like `chat204`, shown by `pigeon chats`.
64
+
65
+ | Flag | Meaning |
66
+ | ---- | ------- |
67
+ | `-n`, `--limit <n>` | how many to show (default 30) |
68
+ | `-s`, `--since <when>` | `30m`, `12h`, `3d`, `2w`, `today`, `yesterday`, or a date like `2026-10-01` |
69
+ | `-u`, `--until <when>` | same forms as `--since` |
70
+ | `-c`, `--chat <chat>` | limit `search` or `watch` to one chat |
71
+ | `-f`, `--file <path>` | attach a file to `send` (repeatable) |
72
+ | `--json` | one JSON object per line |
73
+
74
+ `read` and `search` show the newest matches, printed oldest first like a
75
+ conversation. Dates are local time, in text and in JSON.
76
+
77
+ <details>
78
+ <summary>The JSON shape</summary>
79
+
80
+ ```json
81
+ {
82
+ "id": "6A1F3B7C-…",
83
+ "source": "imessage",
84
+ "chat": { "id": "+12125550102", "name": "Riley Quinn" },
85
+ "from": { "id": "+12125550102", "name": "Riley Quinn" },
86
+ "text": "the wifi password is [redacted]",
87
+ "date": "2026-10-12T14:24:45-07:00",
88
+ "attachments": [],
89
+ "service": "iMessage",
90
+ "redacted": true
91
+ }
92
+ ```
93
+
94
+ `from.id` is `"me"` for messages you sent. `replyTo` holds the id of the
95
+ message being replied to. `attachments` entries have a `name`, a `mime`
96
+ when known, and a `path` when the file is on this Mac (with Messages in
97
+ iCloud, older files may only be in the cloud).
98
+
99
+ </details>
100
+
101
+ ## Make it yours
102
+
103
+ Everything is configured from one file, `~/.config/pigeon/config.ts`. It's
104
+ plain TypeScript: export a function that receives pigeon's API and returns
105
+ your `sources` and `filters`.
106
+
107
+ ```ts
108
+ // ~/.config/pigeon/config.ts
109
+ import type { ConfigFactory } from "@avgvstvs96/pigeon";
110
+
111
+ export default (({ imessage, redact }) => ({
112
+ sources: [imessage()],
113
+ filters: [
114
+ redact({ chat: "+12125550102", on: "2026-10-06" }), // one chat, one day
115
+ redact({ match: /\b\d{3}-\d{2}-\d{4}\b/ }), // anything shaped like an SSN
116
+ (m) => !/verification code/i.test(m.text), // drop 2FA texts entirely
117
+ ],
118
+ })) satisfies ConfigFactory;
119
+ ```
120
+
121
+ Without a config, pigeon reads iMessage with no filters.
122
+
123
+ ### Filters
124
+
125
+ A filter is a function that gets each message and returns:
126
+
127
+ | Return | Effect |
128
+ | ------ | ------ |
129
+ | `true` | keep it |
130
+ | `false` | drop it |
131
+ | a message | show this one instead |
132
+
133
+ Filters run in order, on every command: `read`, `search`, `watch`, and the
134
+ [library](#library). There is no flag to skip them, so whatever reads your
135
+ messages through pigeon (you, a script, an agent) sees exactly what your
136
+ filters allow.
137
+
138
+ pigeon fails closed. A filter that throws drops the message it threw on. A
139
+ config that fails to load stops pigeon with an error instead of running
140
+ without its filters, and so does one with a typo in it (`filter:` instead of
141
+ `filters:`). Search runs *after* filters, so hidden text can't be found by
142
+ searching for it.
143
+
144
+ Filters apply to messages. `pigeon chats` still lists every chat, with its
145
+ name, people and the time of its last message.
146
+
147
+ ### Redact
148
+
149
+ `redact()` is the built-in filter for hiding things. It keeps the message
150
+ but replaces its text with `[redacted]`, so a reader still sees that
151
+ something was said, and when, but not what. Every field narrows the rule:
152
+
153
+ ```ts
154
+ redact({ chat: "+12125550102" }) // a whole chat
155
+ redact({ chat: "chat204", on: "2026-10-06" }) // one day of a group chat
156
+ redact({ from: "+12125550102", since: "2026-09-01" }) // one person, from a date on
157
+ redact({ from: "me", until: "2020-01-01" }) // everything you sent before 2020
158
+ redact({ match: ["gate code", /\d{4}/] }) // just these strings, everywhere
159
+ ```
160
+
161
+ | Field | Matches |
162
+ | ----- | ------- |
163
+ | `chat` | a chat id (one or a list): a phone number or email for 1:1 chats, the id from `pigeon chats` for groups |
164
+ | `from` | a sender's handle, or `"me"` (one or a list) |
165
+ | `on` | a whole day, local time: `"2026-10-06"` |
166
+ | `since` / `until` | a time range: a `Date`, a timestamp, or a date string |
167
+ | `match` | strings (case-insensitive) or regexes to hide in the text and attachment names; without it, the whole message is hidden along with its attachments |
168
+
169
+ Phone numbers match however they're written, so `"212-555-0102"` and
170
+ `"+12125550102"` are the same person. Rules take ids, not names: a contact
171
+ or group can be renamed, and a rule that quietly stopped matching would show
172
+ everything it was hiding.
173
+
174
+ ### Sources
175
+
176
+ iMessage isn't special: it's declared with the same `defineSource` your
177
+ config gets. A source needs an `id` and two functions:
178
+
179
+ ```ts
180
+ import type { Source } from "@avgvstvs96/pigeon";
181
+
182
+ const notes: Source = {
183
+ id: "notes",
184
+ chats: () => [{ id: "inbox", source: "notes", name: "Inbox", people: [], group: false }],
185
+ *messages() {
186
+ yield {
187
+ id: "1",
188
+ source: "notes",
189
+ chat: { id: "inbox", name: "Inbox" },
190
+ from: { id: "me", name: "me" },
191
+ text: "remember the pie",
192
+ date: new Date(),
193
+ attachments: [],
194
+ };
195
+ },
196
+ };
197
+ ```
198
+
199
+ `messages()` yields newest first and may ignore its query (`chat`,
200
+ `since`, `until`): pigeon re-checks every message, so the simplest source is
201
+ just "here are my messages". Add `watch()` to make `pigeon watch` work for it
202
+ and `send()` for `pigeon send`. [`examples/telegram.ts`](examples/telegram.ts)
203
+ is a complete source for Telegram Desktop's JSON export in about 80 lines.
204
+
205
+ Sources merge into one stream by date, and filters apply to all of them:
206
+
207
+ ```ts
208
+ import { telegram } from "./telegram.ts";
209
+
210
+ export default (({ imessage, redact }) => ({
211
+ sources: [imessage(), telegram("~/Downloads/Telegram Desktop/DataExport/result.json")],
212
+ filters: [redact({ match: "the gate code" })],
213
+ })) satisfies ConfigFactory;
214
+ ```
215
+
216
+ `imessage()` takes options too: `db` to read another `chat.db` (a backup,
217
+ or a copy on Linux), `contacts` to point at another Contacts folder or
218
+ `false` to show raw handles, and `id` to tell two iMessage sources apart.
219
+
220
+ ## Library
221
+
222
+ The CLI is a thin layer over the same API, which you can use directly:
223
+
224
+ ```ts
225
+ import { open } from "@avgvstvs96/pigeon";
226
+
227
+ const pigeon = await open(); // your config, your filters
228
+
229
+ for await (const m of pigeon.messages({ chat: "mom", since: new Date("2026-10-01") })) {
230
+ console.log(m.from.name, m.text);
231
+ }
232
+
233
+ for await (const m of pigeon.watch()) {
234
+ if (m.text.includes("call me")) await pigeon.send(m.chat.id, { text: "calling in 5" });
235
+ }
236
+ ```
237
+
238
+ `pigeon({ sources, filters })` builds one without reading a config file.
239
+
240
+ ## For agents
241
+
242
+ Agents read the same transcripts you do, and `--json` when they need
243
+ structure. [`skills/pigeon/SKILL.md`](skills/pigeon/SKILL.md) teaches Claude
244
+ Code, Codex and other skill-aware agents to use pigeon instead of opening
245
+ `chat.db` themselves:
246
+
247
+ ```bash
248
+ cp -r skills/pigeon ~/.claude/skills/
249
+ ```
250
+
251
+ ## How it reads iMessage
252
+
253
+ pigeon opens `chat.db` read-only and never writes to it. It reads message
254
+ text from `attributedBody` when newer macOS leaves `text` empty, merges a
255
+ person's iMessage and SMS threads into one chat like Messages does, and
256
+ reads names straight from the Contacts database, so it works over ssh too.
257
+ Tapbacks and system events (renames, people joining) aren't messages and are
258
+ skipped; tapbacks also quote the message they react to, so skipping them keeps
259
+ hidden text from leaking out through a "Loved …".
260
+
261
+ `watch` wakes on every write to the database and checks every few seconds in
262
+ case an event is missed. `send` uses AppleScript, so it needs Messages to be
263
+ signed in and permission to control it.
264
+
265
+ ## What redaction promises
266
+
267
+ pigeon never hands out what your filters hide, through any command or the
268
+ library. It can't stop other programs from opening `chat.db` themselves:
269
+ anything with Full Disk Access can. Give your agents pigeon and they have no
270
+ reason to go around it. Anything truly sensitive is safest deleted from
271
+ Messages.
272
+
273
+ ## Credits
274
+
275
+ [imsg](https://github.com/steipete/imsg) by Peter Steinberger was a great map
276
+ of `chat.db`'s quirks.
277
+
278
+ ## License
279
+
280
+ MIT
@@ -0,0 +1,82 @@
1
+ import { homedir } from "node:os";
2
+ import type { Chat, Message, Source } from "@avgvstvs96/pigeon";
3
+
4
+ type Text = string | ({ text: string } & Record<string, unknown>);
5
+
6
+ interface Export {
7
+ personal_information?: { user_id: number };
8
+ chats: {
9
+ list: {
10
+ id: number;
11
+ name?: string;
12
+ type: string;
13
+ messages: {
14
+ id: number;
15
+ type: string;
16
+ date_unixtime: string;
17
+ from?: string;
18
+ from_id?: string;
19
+ text: Text | Text[];
20
+ file?: string;
21
+ photo?: string;
22
+ reply_to_message_id?: number;
23
+ }[];
24
+ }[];
25
+ };
26
+ }
27
+
28
+ const plain = (text: Text | Text[]) => [text].flat().map((t) => (typeof t === "string" ? t : t.text)).join("");
29
+
30
+ /**
31
+ * Telegram Desktop's "Export chat history" (JSON) as a pigeon source.
32
+ * Read-only: no watch, no send. Copy this file next to your config to use it.
33
+ */
34
+ export function telegram(path: string): Source {
35
+ let loaded: Promise<{ chats: Chat[]; messages: Message[] }> | undefined;
36
+
37
+ const load = async () => {
38
+ const data: Export = await Bun.file(path.replace(/^~(?=\/)/, homedir())).json();
39
+ const me = data.personal_information && `user${data.personal_information.user_id}`;
40
+ const chats: Chat[] = [];
41
+ const messages: Message[] = [];
42
+ for (const c of data.chats.list) {
43
+ const chat = { id: String(c.id), name: c.name ?? "Saved Messages" };
44
+ const people = new Map<string, string>();
45
+ let last: Date | undefined;
46
+ for (const m of c.messages) {
47
+ if (m.type !== "message") continue;
48
+ const fromMe = m.from_id === me;
49
+ if (!fromMe && m.from_id) people.set(m.from_id, m.from ?? m.from_id);
50
+ last = new Date(Number(m.date_unixtime) * 1000);
51
+ messages.push({
52
+ id: `${c.id}/${m.id}`,
53
+ source: "telegram",
54
+ chat,
55
+ from: fromMe ? { id: "me", name: "me" } : { id: m.from_id ?? "unknown", name: m.from ?? "unknown" },
56
+ text: plain(m.text),
57
+ date: last,
58
+ attachments: [m.photo, m.file].filter((f) => f !== undefined).map((f) => ({ name: f.split("/").pop()! })),
59
+ ...(m.reply_to_message_id && { replyTo: `${c.id}/${m.reply_to_message_id}` }),
60
+ });
61
+ }
62
+ chats.push({
63
+ ...chat,
64
+ source: "telegram",
65
+ people: [...people].map(([id, name]) => ({ id, name })),
66
+ group: !c.type.startsWith("personal"),
67
+ date: last,
68
+ });
69
+ }
70
+ messages.sort((a, b) => b.date.getTime() - a.date.getTime());
71
+ return { chats, messages };
72
+ };
73
+
74
+ return {
75
+ id: "telegram",
76
+ label: "Telegram",
77
+ chats: async () => (await (loaded ??= load())).chats,
78
+ async *messages() {
79
+ yield* (await (loaded ??= load())).messages;
80
+ },
81
+ };
82
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@avgvstvs96/pigeon",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Your messages, from the terminal. An iMessage CLI with pluggable sources and filters.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": "github:AVGVSTVS96/pigeon",
8
+ "keywords": ["imessage", "messages", "cli", "chat.db", "agents", "redaction"],
9
+ "bin": {
10
+ "pigeon": "src/cli/main.ts"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.ts"
14
+ },
15
+ "files": ["src", "examples", "skills", "README.md", "LICENSE"],
16
+ "engines": {
17
+ "bun": ">=1.3"
18
+ },
19
+ "scripts": {
20
+ "test": "bun test",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "^1.3.14",
25
+ "typescript": "^5.9.3"
26
+ }
27
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: pigeon
3
+ description: Read, search, watch and send the user's iMessages (and any other sources they've configured) with the pigeon CLI. Use whenever a task involves their texts or chats, instead of opening chat.db or any Messages database directly.
4
+ ---
5
+
6
+ # pigeon
7
+
8
+ `pigeon` reads the user's messages with their filters already applied. Messages
9
+ they've hidden show up as `[redacted]`; that is intentional, so never try to
10
+ recover them, and never read `chat.db` or a copy of it directly.
11
+
12
+ ```bash
13
+ pigeon chats [name] # find a chat and its id
14
+ pigeon read <chat> [-n 30] # recent messages, oldest first
15
+ pigeon read -s 1d # everything from the last day, all chats
16
+ pigeon search <words...> [-c chat] [-s 2w]
17
+ pigeon watch [chat] --json # stream new messages
18
+ pigeon send <chat> "text" [-f file]
19
+ ```
20
+
21
+ - A chat is its id (a phone number or email for 1:1 chats, `chat…` for groups) or its name. If a name matches several chats, pigeon lists them; retry with the id.
22
+ - `-s/--since` and `-u/--until` take `30m`, `12h`, `3d`, `2w`, `today`, `yesterday` or a date.
23
+ - Add `--json` for one object per line: `chat`, `from` (`id` is `"me"` for the user), `text`, `date` (local time), `attachments`, `replyTo`, `redacted`.
24
+ - Default output is a compact transcript; prefer it for reading, `--json` for processing.
25
+ - Sending is visible to other people: only send when the user asked for that message.
@@ -0,0 +1,65 @@
1
+ import type { Chat, Message } from "../types.ts";
2
+
3
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4
+ const pad = (n: number) => String(n).padStart(2, "0");
5
+
6
+ export interface Style {
7
+ dim(text: string): string;
8
+ bold(text: string): string;
9
+ }
10
+
11
+ export const plain: Style = { dim: (t) => t, bold: (t) => t };
12
+ export const ansi: Style = { dim: (t) => `\x1b[2m${t}\x1b[22m`, bold: (t) => `\x1b[1m${t}\x1b[22m` };
13
+
14
+ /** "Oct 6 21:14", with the year when it isn't this one. */
15
+ export function shortDate(date: Date, now = new Date()): string {
16
+ const year = date.getFullYear() === now.getFullYear() ? "" : ` ${date.getFullYear()}`;
17
+ return `${MONTHS[date.getMonth()]} ${date.getDate()}${year} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
18
+ }
19
+
20
+ /** ISO 8601 in local time with its offset, so a reader sees the same day you do. */
21
+ export function localIso(date: Date): string {
22
+ const offset = -date.getTimezoneOffset();
23
+ const sign = offset >= 0 ? "+" : "-";
24
+ const zone = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
25
+ return (
26
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
27
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${zone}`
28
+ );
29
+ }
30
+
31
+ export function json(value: Chat | Message): string {
32
+ return JSON.stringify(value, function (this: Record<string, unknown>, key, v) {
33
+ const original = this[key];
34
+ return original instanceof Date ? localIso(original) : v;
35
+ });
36
+ }
37
+
38
+ export function chatLine(chat: Chat, style: Style, width: number): string {
39
+ const when = chat.date ? shortDate(chat.date) : "";
40
+ const tag = chat.group ? style.dim(` group of ${chat.people.length + 1}`) : "";
41
+ return `${style.dim(when.padEnd(12))} ${style.bold(chat.name.padEnd(width))} ${style.dim(chat.id)}${tag}`;
42
+ }
43
+
44
+ function body(message: Message): string {
45
+ const files = message.attachments.map((a) => `[attachment: ${a.name}]`);
46
+ return [message.text, ...files].filter(Boolean).join(" ");
47
+ }
48
+
49
+ /** Messages as a transcript, with a header each time the chat changes. */
50
+ export function transcript(style: Style) {
51
+ let chat: string | undefined;
52
+ return (message: Message): string => {
53
+ const lines: string[] = [];
54
+ const key = `${message.source}\0${message.chat.id}`;
55
+ if (key !== chat) {
56
+ chat = key;
57
+ lines.push(style.bold(`── ${message.chat.name}`) + style.dim(` · ${message.chat.id}`));
58
+ }
59
+ const when = shortDate(message.date);
60
+ const [first, ...rest] = `${message.from.name}: ${body(message)}`.split("\n");
61
+ lines.push(`${style.dim(when)} ${first}`);
62
+ for (const line of rest) lines.push(`${" ".repeat(when.length)} ${line}`);
63
+ return lines.join("\n");
64
+ };
65
+ }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from "node:util";
3
+ import { findConfig, loadConfig } from "../config.ts";
4
+ import { pigeon } from "../pigeon.ts";
5
+ import { parseWhen } from "../time.ts";
6
+ import type { Message } from "../types.ts";
7
+ import { ansi, chatLine, json, plain, transcript } from "./format.ts";
8
+
9
+ export const HELP = `pigeon: your messages, from the terminal
10
+
11
+ Usage
12
+ pigeon chats [name] chats, most recent first
13
+ pigeon read [chat] recent messages in a chat, or across all of them
14
+ pigeon search <words...> messages containing every word
15
+ pigeon watch [chat] new messages as they arrive
16
+ pigeon send <chat> [text...] send a message (text from stdin if omitted)
17
+ pigeon config where the config is and what it loaded
18
+
19
+ A chat is its id (a phone number or email for 1:1 chats) or its name.
20
+
21
+ Options
22
+ -n, --limit <n> how many to show (default 30)
23
+ -s, --since <when> "30m", "12h", "3d", "2w", today, yesterday, or a date
24
+ -u, --until <when> same forms as --since
25
+ -c, --chat <chat> limit search or watch to one chat
26
+ -f, --file <path> attach a file to send (repeatable)
27
+ --json one JSON object per line
28
+ -h, --help
29
+
30
+ Filters in ~/.config/pigeon/config.ts apply to every command and can't be skipped.`;
31
+
32
+ const OPTIONS = {
33
+ limit: { type: "string", short: "n" },
34
+ since: { type: "string", short: "s" },
35
+ until: { type: "string", short: "u" },
36
+ chat: { type: "string", short: "c" },
37
+ file: { type: "string", short: "f", multiple: true },
38
+ json: { type: "boolean" },
39
+ help: { type: "boolean", short: "h" },
40
+ } as const;
41
+
42
+ export function parse(argv: string[]) {
43
+ const { values, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
44
+ const limit = values.limit === undefined ? 30 : Number(values.limit);
45
+ if (!Number.isInteger(limit) || limit < 1) throw new Error(`--limit must be a positive whole number`);
46
+ const [command, ...rest] = positionals;
47
+ return {
48
+ command,
49
+ args: rest,
50
+ limit,
51
+ since: values.since === undefined ? undefined : parseWhen(values.since),
52
+ until: values.until === undefined ? undefined : parseWhen(values.until),
53
+ chat: values.chat,
54
+ files: values.file ?? [],
55
+ json: values.json ?? false,
56
+ help: values.help ?? false,
57
+ };
58
+ }
59
+
60
+ type Args = ReturnType<typeof parse>;
61
+
62
+ async function collect(messages: AsyncIterable<Message>): Promise<Message[]> {
63
+ const all: Message[] = [];
64
+ for await (const m of messages) all.push(m);
65
+ return all.reverse();
66
+ }
67
+
68
+ async function run(args: Args, out: (line: string) => void): Promise<void> {
69
+ const style = !args.json && process.stdout.isTTY && !process.env.NO_COLOR ? ansi : plain;
70
+ const format = args.json ? json : transcript(style);
71
+ const print = (m: Message) => out(format(m));
72
+ const config = await loadConfig();
73
+ const p = pigeon(config);
74
+ const { since, until, limit } = args;
75
+
76
+ switch (args.command) {
77
+ case "chats": {
78
+ const query = args.args.join(" ");
79
+ const chats = (query ? await p.findChats(query) : await p.chats()).slice(0, limit);
80
+ const width = Math.min(28, Math.max(0, ...chats.map((c) => c.name.length)));
81
+ for (const chat of chats) out(args.json ? json(chat) : chatLine(chat, style, width));
82
+ return;
83
+ }
84
+ case "read": {
85
+ const chat = args.args.join(" ") || args.chat || undefined;
86
+ (await collect(p.messages({ chat, since, until, limit }))).forEach(print);
87
+ return;
88
+ }
89
+ case "search": {
90
+ const text = args.args.join(" ");
91
+ if (!text) throw new Error("search needs some words to look for");
92
+ (await collect(p.messages({ chat: args.chat, text, since, until, limit }))).forEach(print);
93
+ return;
94
+ }
95
+ case "watch": {
96
+ const chat = args.args.join(" ") || args.chat || undefined;
97
+ for await (const m of p.watch({ chat })) print(m);
98
+ return;
99
+ }
100
+ case "send": {
101
+ const [chat, ...words] = args.args;
102
+ if (!chat) throw new Error("send needs a chat: pigeon send <chat> <text>");
103
+ const piped = words.length === 0 && args.files.length === 0 && !process.stdin.isTTY;
104
+ const text = piped ? (await Bun.stdin.text()).trimEnd() : words.join(" ");
105
+ await p.send(chat, { text, files: args.files });
106
+ return;
107
+ }
108
+ case "config": {
109
+ out(`config ${(await findConfig()) ?? "none"}`);
110
+ out(`sources ${p.sources.map((s) => s.label ?? s.id).join(", ")}`);
111
+ out(`filters ${config.filters?.length ?? 0}`);
112
+ return;
113
+ }
114
+ default:
115
+ throw new Error(`unknown command "${args.command}", see pigeon --help`);
116
+ }
117
+ }
118
+
119
+ export async function main(argv = Bun.argv.slice(2)): Promise<number> {
120
+ try {
121
+ const args = parse(argv);
122
+ if (args.help || !args.command) {
123
+ console.log(HELP);
124
+ return 0;
125
+ }
126
+ await run(args, (line) => console.log(line));
127
+ return 0;
128
+ } catch (err) {
129
+ console.error(`pigeon: ${err instanceof Error ? err.message : err}`);
130
+ return 1;
131
+ }
132
+ }
133
+
134
+ if (import.meta.main) process.exit(await main());