@ghostlytools/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +405 -0
  2. package/SKILL.md +282 -0
  3. package/dist/assets/argon2id-088uQfnH.js +645 -0
  4. package/dist/assets/argon2id-088uQfnH.js.map +1 -0
  5. package/dist/assets/bark_ffi_wasm_bg-Bxru_BVW.wasm +0 -0
  6. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js +6 -0
  7. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -0
  8. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js +6 -0
  9. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -0
  10. package/dist/assets/bitcoindevkit_bg-CRkZTSbO.wasm +0 -0
  11. package/dist/assets/breez_sdk_spark_wasm_bg.wasm +0 -0
  12. package/dist/assets/endpoint-h3cqRyRz.js +185 -0
  13. package/dist/assets/endpoint-h3cqRyRz.js.map +1 -0
  14. package/dist/assets/fedimintWorker.mjs +95 -0
  15. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js +6 -0
  16. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -0
  17. package/dist/assets/fedimint_client_wasm_bg-DeFtkIv6.wasm +0 -0
  18. package/dist/assets/fileBytes-ByS16quP.js +154 -0
  19. package/dist/assets/fileBytes-ByS16quP.js.map +1 -0
  20. package/dist/assets/fileBytesIdb-COzoilo9.js +230 -0
  21. package/dist/assets/fileBytesIdb-COzoilo9.js.map +1 -0
  22. package/dist/assets/fileBytesOpfs-B27wAMUh.js +145 -0
  23. package/dist/assets/fileBytesOpfs-B27wAMUh.js.map +1 -0
  24. package/dist/assets/ghostly_iroh_web-CPa_YEfb.js +1154 -0
  25. package/dist/assets/ghostly_iroh_web-CPa_YEfb.js.map +1 -0
  26. package/dist/assets/ghostly_iroh_web_bg.wasm +0 -0
  27. package/dist/assets/hyperdhtRelay-BKsFpPjr.js +342 -0
  28. package/dist/assets/hyperdhtRelay-BKsFpPjr.js.map +1 -0
  29. package/dist/assets/legacy_ciphers-JJOu0JXL.js +5185 -0
  30. package/dist/assets/legacy_ciphers-JJOu0JXL.js.map +1 -0
  31. package/dist/assets/mainline-DOncVbJN.js +176 -0
  32. package/dist/assets/mainline-DOncVbJN.js.map +1 -0
  33. package/dist/assets/nacl-fast-CDhNCYHs.js +1512 -0
  34. package/dist/assets/nacl-fast-CDhNCYHs.js.map +1 -0
  35. package/dist/assets/noble_curves-DDWn5AH0.js +2978 -0
  36. package/dist/assets/noble_curves-DDWn5AH0.js.map +1 -0
  37. package/dist/assets/noble_hashes-B7AZyFyS.js +427 -0
  38. package/dist/assets/noble_hashes-B7AZyFyS.js.map +1 -0
  39. package/dist/assets/openpgp-BBFr-zHQ.js +16573 -0
  40. package/dist/assets/openpgp-BBFr-zHQ.js.map +1 -0
  41. package/dist/assets/server-5T5l8hfy.js +28293 -0
  42. package/dist/assets/server-5T5l8hfy.js.map +1 -0
  43. package/dist/assets/sha512-BYId-G-T.js +1034 -0
  44. package/dist/assets/sha512-BYId-G-T.js.map +1 -0
  45. package/dist/assets/src-DptDr2et.js +20971 -0
  46. package/dist/assets/src-DptDr2et.js.map +1 -0
  47. package/dist/assets/storage-Bya5b2Xt.js +1648 -0
  48. package/dist/assets/storage-Bya5b2Xt.js.map +1 -0
  49. package/dist/assets/tree-store-N362nuzZ.js +1031 -0
  50. package/dist/assets/tree-store-N362nuzZ.js.map +1 -0
  51. package/dist/assets/unbzip2-stream-B1C-JX9L.js +681 -0
  52. package/dist/assets/unbzip2-stream-B1C-JX9L.js.map +1 -0
  53. package/dist/assets/web-OUQl4Q7L.js +3802 -0
  54. package/dist/assets/web-OUQl4Q7L.js.map +1 -0
  55. package/dist/ghostly.mjs +17087 -0
  56. package/dist/ghostly.mjs.map +1 -0
  57. package/package.json +76 -0
package/README.md ADDED
@@ -0,0 +1,405 @@
1
+ # ghostly: Ghostly for bots and terminals
2
+
3
+ `ghostly` runs **the Ghostly app's own engine** without a screen: the same chats, groups and protocol as the web app,
4
+ the extension and the Desktop, on Node. A bot keeps a profile online with `ghostly daemon`, reads what happens with
5
+ `ghostly listen` (JSON lines), and acts with the other commands (JSON answers). The contract behind it, and why it
6
+ is built this way, is [WISP 11xx](../../docs/wisps/11xx-headless.md). A guided tour: [docs/CLI.md](../../docs/CLI.md).
7
+
8
+ > Status: phases 1 to 5 (profiles, pictures and backups, invites, one chat, private and community groups with their
9
+ > admin tools and hubs, the event stream, hooks and agent turns, wallets and payments, files and voice notes, identity
10
+ > proofs, shared web apps, voice calls, typing, replies, edits, reactions and forwards, Pkarr over the Mainline DHT;
11
+ > the npm package `@ghostlytools/cli`, published from 1.0); every engine call is already
12
+ > reachable through `ghostly engine <method>`. The older Rust `ghostly-cli` (the `cli/` folder) stays as the
13
+ > compatibility client for v0.4 chats; it is not this package, and from 1.0 the release no longer ships it.
14
+
15
+ ## Install
16
+
17
+ From 1.0: `npm install -g @ghostlytools/cli`. Until then, from this repository:
18
+ <!-- release-1.0: "From 1.0:" becomes "Install:", and "Until then, from this repository" becomes "Or from this repository". -->
19
+
20
+ ```bash
21
+ npm install
22
+ npm run build -w @ghostlytools/cli
23
+ npm pack -w @ghostlytools/cli # ghostlytools-cli-<version>.tgz: the bundle, its WebAssembly, README and SKILL.md
24
+ npm install -g ./ghostlytools-cli-*.tgz # the `ghostly` command, with its dependencies from npm
25
+ ghostly --version
26
+ ```
27
+
28
+ Node 22.12 or newer. WebRTC comes from `node-datachannel` (a native
29
+ module with prebuilt binaries for Linux, macOS and Windows); without it the CLI still runs, over HyperDHT, Iroh and
30
+ the DHT, and groups are unavailable.
31
+
32
+ The bundle holds the app's engine (`@ghostly/browser`, `@ghostly/core`, Iroh's and Breez's WebAssembly); every other
33
+ package it imports is a dependency in package.json, which `test/packageDeps.test.ts` keeps true.
34
+
35
+ ## Five minutes
36
+
37
+ ```bash
38
+ ghostly profile set --name "Echo bot" # the name contacts see
39
+ ghostly daemon --detach # keep the profile online
40
+ ghostly invite create --label alice # {"chat":"…","invite":"ghostly1…","link":"https://ghostly.tools/#ghostly1…"}
41
+ ghostly chat wait alice --until live # once Alice joined (in the app, or another ghostly)
42
+ ghostly send alice "hello" # {"chat":"…","messageId":"me_…","delivery":"sending"}
43
+ ghostly listen # one JSON event per line, until stopped
44
+ ```
45
+
46
+ An echo bot ([examples/echo-bot.sh](examples/echo-bot.sh); the event arrives on the command's stdin, never in its
47
+ arguments, so a contact's text cannot reach the shell):
48
+
49
+ ```bash
50
+ ghostly listen --type message.received --cursor ~/.ghostly/echo.cursor --exec '
51
+ event="$(cat)"
52
+ printf "echo: %s" "$(jq -r .message.text <<<"$event")" | ghostly send "$(jq -r .chat <<<"$event")" --stdin'
53
+ ```
54
+
55
+ The same bot on the socket, without jq: [examples/echo-bot.mjs](examples/echo-bot.mjs). A payment bot that takes
56
+ requests, tips and "balance" in a chat, with test coins: [examples/payment-bot.mjs](examples/payment-bot.mjs).
57
+ A Claude Code agent on `listen --turns` with an allowlist (it will not start without one), "thinking" while it works
58
+ and `send --reply` for its answer: [examples/claude-code-agent.sh](examples/claude-code-agent.sh).
59
+
60
+ ## Profiles
61
+
62
+ Each profile is a folder, `~/.ghostly/profiles/<name>/` (0700; files 0600), with its own chats, keys and wallets.
63
+ `--profile <name>` (or `GHOSTLY_PROFILE`) picks one; `ghostly profile use <name>` sets the current one;
64
+ `--home <dir>` (or `GHOSTLY_HOME`) moves the whole folder. The `default` profile is made on first use, others with
65
+ `ghostly profile create <name>`.
66
+
67
+ Only one process opens a profile at a time. When a daemon runs it, every command goes through the daemon's socket.
68
+ When none does, a command runs the profile itself for as long as it needs (a **one-shot**) and leaves: `send` then
69
+ waits until the message went out. Bots should run the daemon: a one-shot is offline between commands.
70
+
71
+ ## Commands
72
+
73
+ Every command prints one JSON object on stdout. A failure prints `{"error":{"code","message"}}` and exits with:
74
+ `1` failed (the engine or the network refused), `2` usage, `3` not found, `4` timed out, `5` needs a confirmation
75
+ (`--force`, `--yes`, `--confirm-real`). A `<chat>` is its id, a unique prefix of it, or its name; a `<group>` too.
76
+
77
+ The rows are in alphabetical order of their first command, and every command of `ghostly help` has one
78
+ (`test/readme.test.ts` checks both).
79
+
80
+ | Command | What it does |
81
+ |---|---|
82
+ | `call auto [on\|off] [--from <chat>]… [--rate n]` | Answer calls by themselves, from anyone or the chats named (kept in the profile) |
83
+ | `call hangup [<chat\|call>]`, `call list`, `call flush [<chat\|call>]` | Hang up (or decline); calls on now; drop the audio queued and not played yet |
84
+ | `call pipe [<chat\|call>]` | A call's audio on stdin and stdout, for shell pipelines (sox, ffmpeg) |
85
+ | `call start <chat> [--rate 48000]`, `call answer [<chat\|call>] [--rate n]` | Voice calls (daemon): the result names the call's audio socket ([Calls](#calls)) |
86
+ | `chat accept <chat> <method> [--off] [--networks mainnet,testnet]` | Which ways of paying the chat takes |
87
+ | `chat connect <chat>`, `chat disconnect <chat> [--hold <minutes>]` | Reconnect now (ends a hold); close the live session, or with `--hold` stay off the direct link that long ([Staying off the direct link](#staying-off-the-direct-link)) |
88
+ | `chat history <chat> [--limit n] [--before x] [--after x]` | Messages, oldest first; `x` is a message id or a time in ms |
89
+ | `chat list`, `chat show <chat>` | Chats, and one chat's connection: transports, last attempt, comparison code |
90
+ | `chat pay <chat> <sats> [--memo t] [--network n] [--confirm-real]` | Ecash to a contact |
91
+ | `chat rename <chat> <name>`, `chat remove <chat> --yes` | Name it here; delete it (keys and history) here |
92
+ | `chat request <chat> <sats> [--memo t] [--method m] [--rail r]`, `chat pay-request <chat> <payment>` | Ask a contact to pay; pay the contact's request |
93
+ | `chat transport <chat> <auto\|dht\|webrtc\|iroh\|hyperdht>` | What carries the chat |
94
+ | `chat verify <chat> --code <code>` | Mark the contact verified after comparing the codes out of band |
95
+ | `chat wait <chat> [--until live\|text\|paired] [--timeout s]` | Wait for a chat to go live, carry text, or see its contact |
96
+ | `daemon [--detach]`, `daemon status`, `daemon stop`, `daemon restart` | Keep the profile online; `restart` stops it and starts this release's code in the background ([After an upgrade](#after-an-upgrade)). Ctrl-C, SIGTERM and the terminal closing (SIGHUP) stop it cleanly |
97
+ | `edit <chat> <message> [text… \| --text <text> \| --stdin] [--force] [--wait none\|confirmed] [--timeout s]` | Replace the text of a message you sent (1:1 chats, `group edit` for a group; `<message>` is the `messageId` `send` gave, or its wire id). The contact sees it in place, marked edited; `--wait confirmed` waits for its app to confirm (the default without a daemon). At most 100 edits a message, no time limit; an older contact app gets it once it shows edits |
98
+ | `engine <method> [json \| -] [--confirm-real] [--show-secret]`, `engine --list` | Any call of the app's engine |
99
+ | `events [--since seq]` | What the event journal holds, without following |
100
+ | `file list <chat>`, `file accept\|decline\|pause\|resume\|cancel [<chat>] <file>` | Transfers; a file over 25 MiB waits for `file accept` (files/3). A file's id names its chat, so `<chat>` may be left out |
101
+ | `file resend [<chat>] <file>`, `file request [<chat>] <file>` | A file that stopped moving: sent again from here, or asked for again from the contact; either goes on from the bytes the receiver holds (files/3) |
102
+ | `file save [<chat>] <file> [--dir d \| --path p] [--force] [--wait [--timeout s]]` | Write a received file to disk (never over one without `--force`; an unfinished one says how many bytes are here; `--wait` waits for it first) |
103
+ | `file send <chat> <path> [--name n] [--mime t] [--voice [ms] [--peaks …]] [--reply <message>]` | A file, or a voice note (its length and waveform measured from the file unless given); `--reply` quotes a message, as `send --reply` does |
104
+ | `file wait [<chat>] <file> [--timeout s]` | Wait until a transfer ends: exit `0` when the file is all here, `1` with the transfer's error when it failed (`details.retry`: `file resend` can go on), `4` on timeout (default 300 s) |
105
+ | `forward <chat\|group> <message>… --to <chat\|group>… [--force] [--wait none\|sent] [--timeout s]` | Forward messages to up to 5 chats and groups (`--to` again for each; `group:<id>` names a group when a chat has the same name; an id that starts with a dash is taken as it is, `--to -Ab…` or `--to=-Ab…`). Each is a new message of yours that says it was forwarded (`forwarded`, the hop count) and nothing of who wrote it. Files go from the bytes this profile holds, never fetched again; a group takes texts only. A text that looks like a seed, a key or ecash needs `--force`. Answers `{from, results: [{to, kind, messageIds, error}]}`; any refusal fails the command (exit 1) with the results in `details`. `--wait sent`: each text on its way (a group's taken by an edge), each file's transfer done |
106
+ | `group create <name> [--mesh]`, `group join <link>` | A community (a link anyone can open), or a private mesh |
107
+ | `group edit <group> <message> [text… \| --text <text> \| --stdin] [--mention <member>]… [--force]` | Replace the text of a message you sent to the group (`<message>` is the `messageId` `group send` gave). Every member sees it in place, marked edited; mentions whose `@name` is still in the text stay, `--mention` adds more. Answers `{group, messageId, edits, sent, edges}`; `--wait sent` and `--timeout` as for `group send`. A private group's members whose edge is down get it when it opens, a community's with the catch-up; a member on an older app keeps the old text |
108
+ | `group hub <group> <member> [--pin \| --exclude \| --auto]` | Past 16 members: pin a member as a hub, keep one from being a hub, or leave it to their app (a daemon offers itself) |
109
+ | `group invite <group> <chat>`, `group remove <group> <member>`, `group admin <group> <member>` | Membership, for the admin (each prints the group; `--show-secret` for its link) |
110
+ | `group leave <group>`, `group forget <group> --yes`, `group accept\|decline <group>` | Membership |
111
+ | `group list [--show-secret]`, `group show <group> [--show-secret]`, `group history <group>` | Groups, members, history; a message names its author (`member` key, `nick` from the roster). The entry link prints as `<hidden>` without `--show-secret` |
112
+ | `group react <group> <message> <emoji> [--remove]` | React to a group's message |
113
+ | `group rename <group> <name>` | A new name, for the admin (1 to 64 characters on one line); the picture stays. Members see it once it reaches them; until then, and on apps from before names, the group keeps the name it had when they got in |
114
+ | `group rotate <group>`, `group link <group> [--off] [--reset]`, `group picture <group> <jpeg> \| --clear` | A fresh secret; the link (printed: asking for it is asking for the secret); the picture |
115
+ | `group send <group> [text…] [--mention <member>]… [--reply <message>] [--wait none\|sent] [--timeout s]` | Send; each mentioned member is written as `@name` in the text. Answers `{group, messageId, sent, edges}`: the id `--reply`, `group react` and `group edit` take, and how many edges took it so far. A group has no receipts: `--wait sent` waits until at least one edge took it (a member's in a private group, one of your hubs' in a community), and exits 4 after `--timeout` (default 30 s) if none did; the message still goes when an edge opens, while the profile is online. Use it for a one-shot send with no daemon |
116
+ | `group typing <group> [--kind typing\|recording\|thinking] [--status "<text>"] [--for s] [--stop]` | `typing` in a private group: the members whose edge is open see "Ana is typing…" (or recording, thinking, or your status line), with the same rules, 6 s hold and `--for`. Answers `{group, typing, kind, status, reached, sendTyping}` (`reached`: members with an open edge). A community does not carry typing yet (exit 1, `unavailable`) |
117
+ | `identity add <provider> [subject] [--signer id] [--field name=value]… [--days n]` | A proof: an in-app signer (NIP-46 and the like) finishes here; a tool or a published record answers with the statement |
118
+ | `identity complete <draft> [--evidence-file f \| --stdin]`, `identity cancel <draft>` | Finish it with the tool's output (or nothing, for a published record); needs the daemon that began it. Cancel drops one not finished |
119
+ | `identity providers`, `identity list` | Kinds of proof and their signers; this profile's proofs |
120
+ | `identity share\|withdraw <chat> <id>`, `identity contact <chat>`, `identity recheck <chat> <id>`, `identity remove <id>` | Show a proof to a contact; what a contact showed, as checked here |
121
+ | `invite create [--label <name>]` | A new chat's `ghostly1…` invite and its link |
122
+ | `invite join <invite-or-link> [--label <name>]` | Join a chat (your own invite is refused) |
123
+ | `lightning default <card>`, `lightning rename <card> <name>` | Lightning cards: which receives by default |
124
+ | `listen [--since seq] [--cursor file] [--type t]… [--turns] [--from <chat\|key>]… [--group <group>]… [--exec cmd] [--webhook url] [--print]` | The event stream; `--from` and `--group` an allowlist, `--turns` one `agent.turn` per message to answer ([Events](#events)) |
125
+ | `message retry\|delete\|details <chat> <message>` | One message |
126
+ | `pay <invoice\|address\|lnurl> [--amount sats] [--network n] [--max-fee sats] [--confirm-real]` | Pay over Lightning (Testnet unless `--network mainnet`) |
127
+ | `payment list [--chat c]`, `payment check <chat> <payment>`, `payment reclaim <payment>` | Payments and requests |
128
+ | `pin <chat\|group> <message> [--remove]` | Pin a message at the top of a chat or group, for everyone in it: one per chat, a new pin replaces it, `--remove` unpins. In a community only the admin pins. A 1:1 pin reaches the contact on the next live session; the other side hears `chat.pinned` or `group.pinned` |
129
+ | `profile backup --out <file>`, `profile restore <file> <new profile>` | An encrypted backup (WISP 05 envelope); the passphrase from `--passphrase-file` or `GHOSTLY_BACKUP_PASSPHRASE` |
130
+ | `profile create <name> [--use] [--name <shown>]`, `profile list`, `profile use <name>` | Profiles |
131
+ | `profile picture <jpeg> \| --clear` | The picture contacts see (a JPEG within 512 px; 128 px is what the app sends) |
132
+ | `profile show`, `profile set [--name <name>] [--share-profile \| --no-share-profile]` | The name contacts see |
133
+ | `react <chat> <message> <emoji> [--remove]` | React to a message with one emoji; a new one replaces yours, `--remove` takes it back |
134
+ | `send <chat> [text…] [--reply <message>] [--stdin] [--force] [--wait none\|sent\|delivered] [--timeout s]` | Send text (arguments, or stdin); `--reply` quotes a message of the chat |
135
+ | `service add <name> <http://127.0.0.1:port>`, `service share <service> <chat> [--off]`, `service list`, `service remove\|enable` | Share a web app on this machine, per contact |
136
+ | `service peer <chat>`, `service open <chat> <service> [--port p]`, `service close <chat> <service>` | A contact's app on a loopback port here (daemon). `url` is a link for your browser, `http://<random>.localhost:<port>/.ghostly-open/<token>`: it sets the service's cookie and shows the app on a host name of its own; any other host name, or a request without that cookie, gets 404. Chrome, Edge and curl reach `*.localhost` names by themselves; Safari and Node's `fetch` on macOS may not (add the name to `/etc/hosts` for them) |
137
+ | `settings get [--show-secret]`, `settings set <key> <json>` | Relays, Iroh relays, the HyperDHT relay, ICE servers, `sendTyping` (false: contacts are never told you type), … |
138
+ | `status` | The profile, its chats, whether WebRTC and calls run, the last event seq |
139
+ | `typing <chat> [--kind typing\|recording\|thinking] [--status "<text>"] [--for s] [--stop]` | Show the contact you are writing, recording or thinking, or a short status line in its place ("Transcribing your audio…", 40 characters, no links): live chats only, it holds 6 s there; `--for s` keeps it on that long (up to 600 s; a one-shot stays that long); a new kind or status shows at once; a message to the chat or `--stop` ends it |
140
+ | `wallet create <type> [--network testnet] [--provider id] [--value name=value]… [--stdin] [--invite code] [--api-key key]` | A wallet: `cashu`, `lightning` (a card: its source's form in `--value`), `arkade`, `spark` (on Mainnet, `--api-key` is your Breez API key), `bitcoin` (BDK), `fedimint` (`--invite` is the federation's invite code), `usdt`. With `--stdin`, secret fields come as `name=value` lines on stdin (`api-key=…` too), out of `ps` and the shell's history |
141
+ | `wallet faucet <type>`, `wallet add-mint <url> [--primary]` | Test coins; another Cashu mint |
142
+ | `wallet list [--network n]` | Wallets and balances, and what `wallet create` can make on each network |
143
+ | `wallet receive <sats>`, `wallet address <type>`, `wallet redeem [<token>]`, `wallet history` | Receive, and what came and went. `wallet redeem` reads the token from stdin when none is given, which keeps it out of `ps` and the shell's history |
144
+ | `wallet remove <type> [--network n] [--card id] [--accept-loss]` | Refused while it holds money or waits for some, unless `--accept-loss` |
145
+
146
+ Help: `ghostly help` lists every command; `ghostly help file` (or `ghostly file --help`) a group; `ghostly help file
147
+ save` (or `ghostly file save --help`) one command, with its options. `-h` works too, except after `--`.
148
+
149
+ Arguments: an option's value is taken as is, even when it starts with `-`. So is an id in a positional that takes
150
+ one (`<chat>`, `<group>`, `<message>`, `<file>`, `<payment>`, `<draft>`, `<id>`, …): drafts, payments and groups
151
+ are base64url, which starts with `-` one time in 64. Any other positional that starts with `-` is refused (a
152
+ mistyped flag must not reach a contact as text): put `--` before a message that starts with a dash.
153
+
154
+ ### Voice notes
155
+
156
+ `file send <chat> <path> --voice` sends an audio file as a voice note. Its length (`--voice <ms>`) and its 64
157
+ waveform bars (`--peaks 0,40,…`, 0-255) may be given; whatever is not given is measured from the file with the
158
+ app's own meter (`voicePeaksOf` in packages/core `voice.ts`: a reading every 50 ms, the loudest per bar), so the
159
+ bars look like the app's recordings. WAV, Opus in WebM or Ogg and MP3 are decoded in the CLI (wasm, no native code);
160
+ AAC in MP4/M4A and other kinds need `ffmpeg` on the PATH (`GHOSTLY_FFMPEG` names another). When the sound cannot be
161
+ read, a note with a given length goes out flat with a warning on stderr (the socket API answers `warning`), and one
162
+ without is refused (`bad_request`). `--voice` takes the next word only when it is a number.
163
+
164
+ ### Files a bot receives
165
+
166
+ `message.received` carries the file: `message.file` is `{id, name, size, mime, voice?}`, and a voice note's `voice`
167
+ is `{duration, peaks?}` (milliseconds, and the 0-255 bars; releases before this one said only `voice: true`). The
168
+ `file.*` events name the file's message and chat (`messageId`, `chat`). So a bot answers a voice note without polling:
169
+
170
+ ```bash
171
+ ghostly listen --type message.received --exec '
172
+ file="$(jq -r ".message.file.id // empty")"
173
+ [ -n "$file" ] && ghostly file save "$file" --dir ./inbox --wait --timeout 120'
174
+ ```
175
+
176
+ `file wait <file>` alone does the waiting: exit `0` when done, `1` with the error when it failed, `4` on timeout.
177
+
178
+ ### Staying off the direct link
179
+
180
+ `chat disconnect <chat>` closes the live session, but the contact's app dials again within seconds.
181
+ `chat disconnect <chat> --hold <minutes>` (up to a week) keeps the chat off its direct link for that long: it is the
182
+ chat's own "DHT only" choice (WISP 400: either side choosing it keeps both off the live link), taken back when the
183
+ time is up. Meanwhile text still goes over the DHT (up to 256 bytes a message; longer ones wait), files, calls and
184
+ typing wait, and the contact's app shows the chat as DHT only. `chat connect`, or `--hold 0`, ends it early. The
185
+ hold is kept in the profile folder: a daemon that restarts takes it back when its time is up, and a DHT only the chat
186
+ had chosen itself (`chat transport dht`) is never undone by it. `chat show` answers `heldUntil` (a time in ms, or
187
+ null; `hold` there is the store-and-forward state, something else), and the stream says `chat.held` and
188
+ `chat.released` (`reason`: expired, connect or lifted).
189
+
190
+ `settings set online false` is different: the whole profile goes offline, every chat, DHT included, and nothing
191
+ arrives until it is back online.
192
+
193
+ ### After an upgrade
194
+
195
+ A daemon keeps running the code it started with. After upgrading the CLI, every command that goes through an older
196
+ (or newer) daemon still answers, and says on stderr `ghostly: the daemon runs <version> and this command is
197
+ <version>`. `ghostly daemon restart` stops the running daemon (if one runs) and starts this release's in the
198
+ background, as `daemon --detach` does. A daemon kept by a service manager (systemd, launchd) is restarted with the
199
+ service manager instead.
200
+
201
+ ### The secret guard
202
+
203
+ `send` and `group send` refuse text that looks like a recovery phrase, a private key (nsec, WIF, xprv, 64 hex
204
+ characters named as a key) or a Cashu token (money anyone who reads it can take), with exit `5`, unless `--force`.
205
+ It is the app's own detector.
206
+
207
+ ### Real money
208
+
209
+ A spend on Mainnet needs the engine's `confirmedReal` (the app asks the person). The CLI sets it only with
210
+ `--confirm-real`, and refuses a `confirmedReal` it was not given that flag for. Test networks need nothing.
211
+
212
+ ### Identities on Node
213
+
214
+ SSH (`ssh-keygen`), OpenPGP (`gpg`) and Bitcoin (a wallet's "sign message") proofs are made with the tool: `identity
215
+ add` prints the statement and the command, `identity complete` takes its output. A domain or a DID publishes a record,
216
+ then `identity complete` asks the engine to look it up. In-app signers (a Nostr signer over NIP-46, Pubky, AT
217
+ Protocol) run in one `identity add`, with their fields in `--field`, and print what they wait on (a link, a code) to
218
+ stderr. OpenID Connect needs a browser window: make that proof in the app.
219
+
220
+ ### Wallets on Node
221
+
222
+ Cashu (and Lightning through the mints), Lightning cards (NWC, LND, Core Lightning, Breez, Fedimint; a Lightning address or LNURL is paid with `pay`), Arkade,
223
+ Spark, BDK, Fedimint and USDT run as in the app. One does not yet: **Bark** (its SDK ships a browser build only);
224
+ `wallet list` offers it as unavailable, with the reason. A Fedimint wallet joins a federation by its invite
225
+ (`wallet create fedimint --invite fed1…`); its Lightning card is `wallet create lightning --provider fedimint --value
226
+ federation=<id>` (`engine fedimintPreview '{"invite":"fed1…"}'` shows the id first). Its client runs in a worker
227
+ thread, with one database file per federation in the profile's `fedimint/` folder. No wallet is made by itself: a bot has exactly the wallets it created. A Cashu test mint's invoice (from
228
+ `wallet receive`) is credited once its payer vouches for it in a chat, as in the app (a test mint says every invoice
229
+ is paid); on a real mint, the mint's answer decides. A Testnet wallet pays a Bitcoin (`lnbc`) invoice only through
230
+ the public test mint, as in the app: a mint on this machine may have real sats behind it. A local test mint with a fake
231
+ Lightning backend (a regtest or e2e stack) is declared with `GHOSTLY_TEST_MINTS=<url,…>`.
232
+
233
+ ### Secrets
234
+
235
+ Nothing prints a seed, a key, a wallet phrase or ecash unless asked with `--show-secret`: `settings get` masks
236
+ credentials, and engine calls that answer with secrets (`exportLinks`, `walletExport`, the wallets' backups) are
237
+ refused without it. A group's entry link lets anyone who reads it join: `group show`, `group list`, the admin
238
+ commands, `group.created` events and `engine getState` print it as `<hidden>`; `--show-secret`, `group create` and
239
+ `group link` print it.
240
+
241
+ ## Events
242
+
243
+ `ghostly listen` prints one event per line:
244
+
245
+ ```json
246
+ {"seq":6,"id":"message.received:f3gg…:peer_jY7N…","type":"message.received","at":1790450767762,"chat":"f3gg…","message":{"id":"peer_jY7N…","chat":"f3gg…","from":"peer","text":"hello bob","timestamp":1790450767735,"delivery":null,"deliveryError":null,"via":"datalink","nick":null}}
247
+ ```
248
+
249
+ - `seq` grows by one per event in the profile, across restarts; `id` is the same whenever the same fact is reported.
250
+ Dedupe by `id`; resume with `--since <seq>`, or let `--cursor <file>` remember the last event handled.
251
+ - Types: `daemon.started`, `chat.created`, `chat.removed`, `chat.renamed`, `chat.pairing` (`stage`: publishing,
252
+ waiting, resolving, on-dht, live, …), `chat.connection` (`live`, `transport`), `typing.started` (with `kind` and, when given, `status`; again when either changes) and `typing.stopped` (the contact is writing, or stopped: a message, a stop, or 6 s of silence), `chat.joined` (the contact's app
253
+ announced itself; not a message), `chat.announced`, `message.received`, `message.sent`, `message.delivery`
254
+ (`delivery`: sending, queued, waiting, held, sent, delivered, failed), `message.edited` (a text changed in place, the
255
+ contact's or mine: once per edit number, with `edits` and the message as it is now; `group.message.edited` in a group), `message.deleted`, `group.created`,
256
+ `group.status`, `group.members` (`joined`, `left`), `group.typing.started` (`member`, `kind`, and `status` when given; again when either changes) and `group.typing.stopped` (`member`: a member of a private group is writing, or stopped), `group.message` (`message.member` is the author's key,
257
+ `message.nick` their name from the roster; `message.mentioned` when it names this profile), `group.sent`, `group.event`, `file.offered` (a file over 25 MiB waits for `file accept`),
258
+ `file.stage`, `file.done`, `file.failed` (each with `chat`, `file`, `messageId`), `chat.held` and
259
+ `chat.released` (`chat disconnect --hold`), `identity.received` and `identity.status` (what a contact
260
+ showed, as checked here), `identity.approval` and `identity.progress` (a signer waits on a link or a code), `group.deleted`, `group.removed`, `payment.created` and
261
+ `payment.updated` (`payment`: id, chat, kind request|payment, direction in|out, amount, memo, state pending|
262
+ settled|failed, network, method), `call.incoming`, `call.outgoing`, `call.connected` and `call.ended` (voice
263
+ calls: see [Calls](#calls)), `events.gap` (the journal no longer holds what `--since` asked for).
264
+ - A message that answers another carries `replyTo`: `{id, snippet, from, member?, found}`. `id` is the original's
265
+ message id in this chat when it is here (`found: true`, and `snippet` and `from` come from that copy), else the id
266
+ the reply named; `from` is `me`, `peer` or null (only the id came, over the DHT). Answer one with
267
+ `ghostly send <chat> --reply <message id> "…"` (`group send` too): the id from history or from the event.
268
+ - Reactions (WISP 400 § Reactions): `message.reaction` (a chat) and `group.reaction` (a group) say each change once:
269
+ `{messageId, by, emoji, removed, mine}`, with `by` `me`, `peer` or a member key, `emoji` "" when taken back, `mine`
270
+ when the message is this profile's. `chat history` lists each message's `reactions`: `[{by, emoji, at}]`.
271
+ - Pins (WISP 400 § Pinned message): `chat.pinned` (with `chat`) and `group.pinned` (with `group`) when someone else pins
272
+ or unpins: `{messageId, ref, by, removed}`, with `messageId` the message's id here (null when it is not here, or
273
+ unpinned), `ref` the id both sides know it by, and `by` `peer` or a member key.
274
+ - `--type message.received` keeps one type; `--type message.` (or `message.*`) a family.
275
+ - `--from <chat|key>` and `--group <group>` (again for more): an allowlist. Only the chats (id, prefix, name, or the
276
+ contact's `peer` key, which also matches a chat made later) and groups named get through; any other chat's or
277
+ group's event stops before `--exec`, `--webhook` and stdout (the message stays in the chat; the cursor moves past
278
+ it). The profile's own events (`daemon.started`, `events.gap`, `identity.approval`) pass. Names become ids once,
279
+ at the start. A flag, not profile state: each listener (each agent) has its own ([docs/CLI.md](../../docs/CLI.md#allowlist)).
280
+ - `--turns`: one `agent.turn` event per `message.received`, and per `group.message` that mentions this profile:
281
+ `{seq, id: "agent.turn:<source id>", type, at, source, chat | group + member, messageId, timestamp, untrusted:
282
+ {text, name, replyTo?: {id, snippet}, file?: {id, name, size, mime, voice}}}`. `seq` is the source's (cursors
283
+ work), `id` stable per message. What the sender wrote is under `untrusted` only: give it to an agent as data, never
284
+ as its instructions ([docs/CLI.md](../../docs/CLI.md#agent-turns)). Takes the place of `--type`.
285
+ - `--exec <cmd>` runs the command through the shell once per event, in order, with the event on stdin and
286
+ `GHOSTLY_EVENT_TYPE`, `GHOSTLY_EVENT_ID`, `GHOSTLY_EVENT_SEQ` in its environment.
287
+ - `--webhook <url>` POSTs each event (JSON) to a local bridge: `127.0.0.1`, `localhost` or `[::1]` only.
288
+ - With no daemon running, `listen` becomes the daemon (socket included), so a hook can answer with `ghostly send`.
289
+
290
+ As the app's chat screen does, the side that joined a chat says `👋 <name> joined` once it first goes live, and the
291
+ side that invited answers once.
292
+
293
+ ## The socket API
294
+
295
+ The daemon listens on a Unix socket in the profile's folder (`daemon.sock`, 0600; in `/tmp/ghostly-<hash>/`, a
296
+ folder of the user's alone, when the folder's path is too long for a socket). One JSON object per line each way:
297
+
298
+ ```json
299
+ {"id":1,"method":"chat.send","params":{"chat":"alice","text":"hi","wait":"sent"}}
300
+ {"id":1,"result":{"chat":"f3gg…","messageId":"me_…","delivery":"sent"}}
301
+ {"id":2,"method":"events.subscribe","params":{"since":40}}
302
+ {"event":{"seq":41,"type":"message.received",…}}
303
+ ```
304
+
305
+ Methods: `status`, `profile.get|set`, `settings.get|set`, `invite.create|join`, `chat.list|get|history|send|retry|
306
+ delete|details|rename|remove|transport|connect|disconnect|verify|wait|pay|request|payRequest|accept|edit|react|pin|forward|typing`,
307
+ `group.create|join|list|get|history|send|edit|react|leave|forget|accept|decline`, `wallet.list|create|remove|faucet|history|
308
+ receive|address|redeem`, `wallet.mint.add`, `lightning.default|rename`, `pay`, `payment.list|check|reclaim`,
309
+ `file.send|list|action|wait|save`, `group.invite|remove|admin|rotate|link|picture|hub`, `profile.picture|backup`,
310
+ `identity.providers|list|add|complete|cancel|remove|share|withdraw|contact|recheck`, `service.list|add|remove|enable|share|peer|open|close`,
311
+ `call.start|answer|hangup|list|get|flush|auto`,
312
+ `events.replay`, `events.subscribe`, `daemon.stop`, and `engine.call` with
313
+ `{"method":"<engine call>","params":{…},"confirmReal":false}` for anything else the app does. Parameters are the
314
+ commands' (see `src/api.ts`). Errors: `{"id":…,"error":{"code","message","details"?}}` with the codes above.
315
+
316
+ ## Calls
317
+
318
+ A bot or an agent can take part in a voice call with the apps (calls/1, WISP 601): the CLI runs the call's media and
319
+ hands its audio to a program of yours, over a Unix socket per call. Speech-to-text, a model or text-to-speech are the
320
+ program's business, not Ghostly's. Audio only: a video call is answered as a voice call (the contact's camera is not
321
+ shown, and nothing is sent on its video lane). Calls need the daemon.
322
+
323
+ ```sh
324
+ ghostly daemon --detach
325
+ ghostly call auto on --from alice # or answer each one: ghostly call answer alice
326
+ ghostly listen --type call. # call.incoming … call.connected {"audio":{"socket":…}}
327
+ node examples/call-echo.mjs greeting.wav # a program that answers, greets, then echoes after 1 s
328
+ ```
329
+
330
+ **The audio.** The socket is `<profile>/calls/<call>.sock` (0600), or `/tmp/ghostly-calls-<hash>/<call>.sock` (in a
331
+ folder of yours alone, 0700) when that path is too long; `call start`, `call answer`, `call list` and the
332
+ `call.connected` event name it. It carries raw PCM,
333
+ with no framing:
334
+
335
+ - s16le, mono, at the call's rate: 48000 by default, or 24000, 16000, 12000, 8000 with `--rate`. Opus runs at that
336
+ rate itself, so nothing is resampled.
337
+ - From the call: the contact's audio in 20 ms frames (1920 bytes at 48 kHz, 640 at 16 kHz), as they are decoded.
338
+ - To the call: any amount, at any time. Writing faster than real time is fine: the CLI queues it (up to 5 minutes),
339
+ sends a 20 ms frame per tick at real time, and silence when the queue is empty. A partial frame waits 60 ms for the
340
+ rest of it, then plays padded.
341
+ - Barge-in: `ghostly call flush` (or `call.flush` on the socket API) drops everything queued, at once. Stop writing
342
+ before you flush.
343
+ - The socket exists from `call start`/`call answer`, so the program may connect before the media is up. One program
344
+ at a time: a new connection replaces the old one. When the call ends, the program reads EOF and the socket is
345
+ removed.
346
+ - Added latency inside the CLI: at most one 20 ms frame going out; coming in, a frame as soon as its packet is
347
+ decoded (up to 40 ms more when a packet is late; a lost one becomes 20 ms of silence).
348
+
349
+ **Events.** `call.incoming` `{call, chat, name, video, auto}`, `call.outgoing` `{call, chat, name, audio}`,
350
+ `call.connected` `{call, chat, direction, audio: {socket, rate, channels, format, frameMs}}`, and `call.ended`
351
+ `{call, chat, direction, reason, duration?}` with `reason`:
352
+
353
+ | `reason` | |
354
+ |---|---|
355
+ | `hangup` | This side hung up, or declined a call that rang |
356
+ | `remote-hangup` | The contact hung up a connected call |
357
+ | `missed` | An incoming call stopped ringing unanswered (the contact gave up, or its offer went stale after 120 s) |
358
+ | `rejected` | The contact declined this side's call |
359
+ | `unanswered` | This side's call rang 60 s with no answer |
360
+ | `failed` | The media did not connect within 30 s, or dropped |
361
+ | `stopped` | The daemon stopped (its calls are hung up first) |
362
+
363
+ **Rules**, as in the apps: one call per chat, several chats may each have one; a call needs the chat live (the call
364
+ buttons of the contact's app say why when not); an offer that is older than 120 s does not ring. `call auto` is
365
+ kept in the profile's `calls.json`. The media is libdatachannel (node-datachannel, as for chats) and libopus built to
366
+ WebAssembly (`opusscript`); `status` says `calls: false` and `call list` says why where they cannot run.
367
+ `GHOSTLY_CALL_BIND=127.0.0.1` binds the call's media to one address (the tests use loopback).
368
+
369
+ **When a call does not connect** (it stays `connecting`, then ends `failed`): the daemon's log has a line per call for
370
+ the candidates each side offered, every ICE state, and the pair it connected over; `call list` shows the ICE state
371
+ and the pair as `stats.ice`. The macOS app and Safari hide their addresses behind mDNS names (`<uuid>.local`) that
372
+ often resolve nowhere, so such a call connects only when the app's own checks reach the CLI. A VPN that is the
373
+ default route (seen with NordVPN on the same Mac as the CLI) makes the app send from the tunnel's address, which
374
+ nothing on that machine can answer: allow local network traffic in the VPN, or pause it.
375
+
376
+ **A TURN relay.** Calls use the apps' STUN servers, then the profile's own ICE servers:
377
+ `ghostly settings set iceServers '[{"urls":"turn:turn.example.org:3478","username":"…","credential":"…"}]'` (the
378
+ same setting chats use). A relay connects the calls no direct path can: a VPN that takes every packet through its
379
+ tunnel, a NAT that maps each destination apart. The media stays encrypted end to end; the relay sees both
380
+ addresses and when and how much is sent.
381
+
382
+ ## Transports
383
+
384
+ The engine picks as the app does: WebRTC (libdatachannel), HyperDHT (native, in process), Iroh (the wasm build,
385
+ relay only, as in the web app), and the DHT floor through the Pkarr relays in the settings and the Mainline DHT
386
+ directly (every packet to both; reads from the DHT when every relay fails). Tests and private networks:
387
+ `GHOSTLY_DHT=0` leaves the Mainline DHT out, `GHOSTLY_DHT_BOOTSTRAP=host:port,…` replaces its bootstrap routers,
388
+ `GHOSTLY_HYPERDHT_BOOTSTRAP=host:port,…` replaces HyperDHT's bootstrap nodes, `settings set relays
389
+ '["http://…"]'` the Pkarr relays, `settings set irohRelays '["https://…"]'` the Iroh relays. `GHOSTLY_WEBRTC=0`
390
+ turns WebRTC off. A daemon offers to be a hub of the private groups past 16 members it is in, since it stays online;
391
+ `GHOSTLY_HUB=0` keeps it a plain member (the admin can still pin it).
392
+
393
+ ## Not yet
394
+
395
+ - **Bark** wallets (see above), **OpenID Connect** proofs (a browser window), and video in calls
396
+ (calls are voice only).
397
+ - **A single binary**: the CLI needs Node.
398
+ - Link previews made by the sender, and holding messages for an away contact.
399
+
400
+ ## Tests
401
+
402
+ `npm test -w @ghostlytools/cli` builds the CLI and runs the unit tests and a two-bot end-to-end test on loopback (a Pkarr
403
+ relay in the test process and a HyperDHT testnet), a voice call between them included.
404
+ `e2e/web/headless-chat.spec.ts` puts a bot and the web app in one chat; `e2e/web/headless-call.spec.ts` has them
405
+ call each other, with a tone each way.