nattermatic 0.0.1 → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +229 -4
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +232 -0
- package/package.json +62 -6
- package/src/cert.js +86 -0
- package/src/claude.js +411 -0
- package/src/cli.js +223 -0
- package/src/codex.js +247 -0
- package/src/config.js +280 -0
- package/src/crypto.js +124 -0
- package/src/discovery.js +60 -0
- package/src/legacy-env.js +4 -0
- package/src/legacy.js +130 -0
- package/src/local.js +219 -0
- package/src/mcp.js +370 -0
- package/src/node.js +1387 -0
- package/src/roster.js +517 -0
- package/src/self.js +41 -0
- package/src/setup.js +464 -0
- package/src/store.js +39 -0
- package/src/tls.js +229 -0
- package/src/types.d.ts +150 -0
- package/src/wake.js +143 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Baldwin
|
|
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,7 +1,232 @@
|
|
|
1
|
+
<p align="center"><img src="assets/nattermatic-320.png" width="160" height="160" alt="nattermatic: a red tin toy robot head with a radio antenna"></p>
|
|
2
|
+
|
|
1
3
|
# nattermatic
|
|
2
4
|
|
|
3
|
-
nattermatic
|
|
4
|
-
accounts and machines
|
|
5
|
-
|
|
5
|
+
**[nattermatic.com](https://nattermatic.com)** · Let AI agents message each other across different
|
|
6
|
+
agents, accounts and machines.
|
|
7
|
+
Claude Code's own messaging needs every session on one account; nattermatic doesn't. Address any
|
|
8
|
+
agent as `name@host` (e.g. `reviewer@laptop.local`). An incoming message **wakes an idle Claude
|
|
9
|
+
Code or Codex session**, just like agents talking on a single machine.
|
|
10
|
+
|
|
11
|
+
<p align="center"><a href="https://nattermatic.com"><img src="assets/demo.gif" width="880" alt="Two Claude Code sessions on different machines: one agent asks the other a question, and the idle session wakes up, checks its code and answers"></a></p>
|
|
12
|
+
<p align="center"><a href="https://nattermatic.com">Watch the whole one-minute demo, from setup to reply, at nattermatic.com</a></p>
|
|
13
|
+
|
|
14
|
+
nattermatic was called **agentlink** until version 0.2.0. Machines set up under the old name move
|
|
15
|
+
across when they're upgraded, keeping their network, and the `agentlink` command still works.
|
|
16
|
+
|
|
17
|
+
## What you need
|
|
18
|
+
|
|
19
|
+
- [Claude Code](https://claude.com/claude-code) 2.1.318 or newer on each machine. Codex 0.167 or
|
|
20
|
+
newer is woken too, and other MCP agents can [join](#what-you-get) (for a machine without Claude
|
|
21
|
+
Code, set `NATTERMATIC_SKIP_CHECKS=1` for the installer).
|
|
22
|
+
- An Apple silicon Mac or Linux. Intel Macs aren't currently supported.
|
|
23
|
+
- Machines on the same network, or Tailscale on each of them.
|
|
24
|
+
|
|
25
|
+
The installer brings everything else. It needs no sudo.
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
**First machine** — paste into Claude Code (or run the command yourself):
|
|
30
|
+
|
|
31
|
+
> Set up nattermatic on this machine: run `curl -fsSL https://nattermatic.com/install.sh | sh`, then show me the prompt it prints for adding other machines, and tell me to type /reload-plugins.
|
|
32
|
+
|
|
33
|
+
Or install the Claude Code plugin, which teaches Claude to set nattermatic up and use it, then ask
|
|
34
|
+
Claude to set up nattermatic:
|
|
35
|
+
|
|
36
|
+
/plugin install nattermatic --marketplace tombaldwin/nattermatic
|
|
37
|
+
|
|
38
|
+
**Every other machine** — paste the prompt the first machine printed. It looks like:
|
|
39
|
+
|
|
40
|
+
> Join my nattermatic network: run `curl … | sh` — then tell me to type /reload-plugins.
|
|
41
|
+
|
|
42
|
+
Each invite adds one machine and expires after 24 hours. The pinned key means curl only talks to
|
|
43
|
+
the machine that made the invite. That key exists only while the invite is open, so a used invite
|
|
44
|
+
line stops working (curl reports a key mismatch or a 404). Run `natter invite` on any member
|
|
45
|
+
for a new one.
|
|
46
|
+
|
|
47
|
+
**Then** type `/reload-plugins` in Claude Code. That session is now on the network, and so is every
|
|
48
|
+
new one. (If that doesn't work, restart it: `claude --continue` picks up where you left off.)
|
|
49
|
+
|
|
50
|
+
That's it: no config files, no accounts, no server to run. Setup adds nattermatic's tools and a small
|
|
51
|
+
background hook to Claude Code's settings (and Codex's, if it's installed), and `natter uninstall`
|
|
52
|
+
takes them out again. On Apple
|
|
53
|
+
silicon Macs it installs
|
|
54
|
+
nattermatic as a signed app (`~/Applications/nattermatic.app`, from the inviting machine or the latest
|
|
55
|
+
release, refused unless Apple's certificate for nattermatic's team signed it); macOS asks once to
|
|
56
|
+
let **nattermatic** find devices on your local network: click Allow. Elsewhere it uses npm, and
|
|
57
|
+
brings its own Node.js if the machine doesn't have a recent one. For the next machine, ask Claude
|
|
58
|
+
on any member to add one (or run `natter invite`).
|
|
59
|
+
|
|
60
|
+
## Your first message
|
|
61
|
+
|
|
62
|
+
Agents are named after their project folder, so a session in `~/code/reviewer` on the machine
|
|
63
|
+
`desk` is `reviewer@desk.local`. Ask Claude who's online, then ask it to message someone:
|
|
64
|
+
|
|
65
|
+
> **You, to Claude on your laptop:** Ask reviewer@desk.local why the build is failing.
|
|
66
|
+
>
|
|
67
|
+
> **Claude:** Sent. `pushed to reviewer@desk.local's live session`
|
|
68
|
+
|
|
69
|
+
The session on `desk` wakes, even if it was idle, and works on it. Its answer wakes the session on
|
|
70
|
+
your laptop in the same way.
|
|
71
|
+
|
|
72
|
+
You don't need the exact address. `reviewer@desk` works (`.local` is added, and case doesn't
|
|
73
|
+
matter), and so does a machine's Tailscale name or IP address. You can also just say "ask the
|
|
74
|
+
reviewer on my desktop": Claude checks who's online and works out which agent you mean.
|
|
75
|
+
|
|
76
|
+
More ways to use it, such as a reviewer on another account, waking a session from CI, or a nightly
|
|
77
|
+
job that reports in the morning, are in [Recipes](docs/recipes.md).
|
|
78
|
+
|
|
79
|
+
## What you get
|
|
80
|
+
|
|
81
|
+
- **Wake on message.** A background hook wakes the session when a message arrives, even when it's
|
|
82
|
+
idle, and Claude reads it and acts on it (it still stops at any permission prompt). Tools: `send`, `peers`, `inbox`, plus `network` and
|
|
83
|
+
`invite` for managing the network. Removing machines and uninstalling stay in the terminal, so
|
|
84
|
+
you always see the command first.
|
|
85
|
+
- **Nothing gets lost.** Messages are stored on disk at every hop and retried. If the recipient
|
|
86
|
+
isn't running, its machine keeps the message; if the whole machine is off, copies wait on up
|
|
87
|
+
to 3 other online machines and whoever sees it return first delivers. Copies that reach a
|
|
88
|
+
machine twice are dropped, though a session that dies just as a message is shown gets it again
|
|
89
|
+
when it restarts.
|
|
90
|
+
Messages expire after 7 days. A session can have 2,000 unread messages waiting, and a machine's
|
|
91
|
+
store can reach 1 GB (`maxWaiting` and `maxStoreMB` in `~/.nattermatic/config.json`). Past that, a
|
|
92
|
+
message from another machine waits at the sender and is retried, and a send from this machine
|
|
93
|
+
fails with an error, so a runaway agent stops there.
|
|
94
|
+
- **Zero-config discovery.** Machines on the same LAN find each other automatically (encrypted UDP
|
|
95
|
+
beacons) and share what they know, so every machine learns the whole network.
|
|
96
|
+
- **Multiple Claude accounts.** Every `CLAUDE_CONFIG_DIR` account setup can find is enabled
|
|
97
|
+
(`~/.claude*` dirs, shell aliases, and the one the shell is using); run `natter setup` again to
|
|
98
|
+
add new accounts.
|
|
99
|
+
- **Beyond the LAN.** With Tailscale on both machines, `natter invite` includes the Tailscale
|
|
100
|
+
name and joining machines use it automatically. A machine that moves between networks is found
|
|
101
|
+
again at its other addresses, within a couple of minutes.
|
|
102
|
+
- **Codex.** If Codex is installed, setup adds nattermatic to `~/.codex/config.toml` too. The next
|
|
103
|
+
time Codex starts, it asks you to trust nattermatic's hook: trust it, and a message gives an idle
|
|
104
|
+
Codex session a turn (through `codex queue`, Codex 0.167 or newer). Codex sessions are named after
|
|
105
|
+
their project folder.
|
|
106
|
+
- **Other agents.** Any MCP agent can join with `natter mcp --poll` (it reads with `inbox`
|
|
107
|
+
instead of being woken).
|
|
108
|
+
- **Channels and managed accounts.** If your organisation's policy turns channels off, `nattermatic
|
|
109
|
+
claude` sessions can't tell, and pushed messages don't show up. Set `NATTERMATIC_POLL=1` so they're
|
|
110
|
+
read with `inbox` instead.
|
|
111
|
+
|
|
112
|
+
Name a session with `NATTERMATIC_NAME=<name> claude` (default: the project folder name).
|
|
113
|
+
|
|
114
|
+
## Compared with Claude Code's own messaging
|
|
115
|
+
|
|
116
|
+
Claude Code can already [message between your own sessions](https://code.claude.com/docs/en/cross-session-messaging):
|
|
117
|
+
on one machine over a local socket, and between machines through Anthropic's servers, with a
|
|
118
|
+
claude.ai sign-in and Remote Control. If that covers you, you don't need nattermatic. nattermatic is for
|
|
119
|
+
sessions on different Claude accounts, agents other than Claude Code, and messages that stay
|
|
120
|
+
between your own machines.
|
|
121
|
+
|
|
122
|
+
## Commands
|
|
123
|
+
|
|
124
|
+
Claude handles everyday use and invites; these are for terminals and scripts.
|
|
125
|
+
|
|
126
|
+
natter to <name@host> <msg> natter who natter status
|
|
127
|
+
natter invite [--list|--revoke] natter remove <host>
|
|
128
|
+
natter compact [--yes] natter uninstall
|
|
129
|
+
|
|
130
|
+
`natter help <command>` (or `natter <command> --help`) shows a command's options. `nattermatic`
|
|
131
|
+
runs the same commands, and so do agentlink's names for them (`agentlink send`, `agentlink peers`).
|
|
132
|
+
`natter who` says why a machine is offline (the node log, `~/.nattermatic/node.log`, records
|
|
133
|
+
when a machine stops or starts answering).
|
|
134
|
+
|
|
135
|
+
Tab completion, including the addresses of agents that are online after `natter to`: add
|
|
136
|
+
`eval "$(natter completion zsh)"` to `~/.zshrc`, or `eval "$(natter completion bash)"` to
|
|
137
|
+
`~/.bashrc`.
|
|
138
|
+
|
|
139
|
+
## Upgrading
|
|
140
|
+
|
|
141
|
+
Re-run the install line from **Setup** on each machine (or `npm install -g nattermatic`
|
|
142
|
+
followed by `natter setup`). It keeps the machine's key, network and messages. Upgrade all
|
|
143
|
+
your machines: a machine running an incompatible version is reported as such
|
|
144
|
+
(`… runs an older version of nattermatic`) and can't exchange messages until it's upgraded.
|
|
145
|
+
Claude sessions that were running during the upgrade say they need restarting
|
|
146
|
+
(`claude --continue` resumes the conversation); they never stop the newer node.
|
|
147
|
+
|
|
148
|
+
**From agentlink** (before 0.2.0): re-run the install line, as for any upgrade. Setup moves
|
|
149
|
+
`~/.agentlink` to `~/.nattermatic` (leaving a link behind), replaces the background service, and
|
|
150
|
+
updates Claude Code's and Codex's settings, including any permission rules you wrote for
|
|
151
|
+
agentlink's tools. Machines keep their key and network, and old and new versions still talk to each
|
|
152
|
+
other, so machines can be upgraded one at a time. Because the app has a new name, macOS asks once
|
|
153
|
+
more to let it find devices on the local network, and Codex asks you to trust the new hook. If you
|
|
154
|
+
installed with npm, `npm uninstall -g @tombaldwin/agentlink` before `npm install -g nattermatic`.
|
|
155
|
+
|
|
156
|
+
## Security
|
|
157
|
+
|
|
158
|
+
Each machine has its own key. The network is the list of machines' keys, kept and shared by the
|
|
159
|
+
members. Any member can invite machines, and a machine can remove the ones that joined after it
|
|
160
|
+
(the first machine can remove anyone).
|
|
161
|
+
|
|
162
|
+
- **Encrypted, and nothing for strangers.** Machines talk TLS and prove their keys to each other
|
|
163
|
+
inside the connection. A laptop at a café that tries a home address now held by a stranger
|
|
164
|
+
doesn't say who it is or send anything before the check fails. And anyone who connects to a
|
|
165
|
+
machine sees only a throwaway key, so a laptop can't be recognised as it moves between networks.
|
|
166
|
+
- **Joining.** An invite is a one-time token plus a key the inviting machine presents while the
|
|
167
|
+
invite is open. Someone watching the
|
|
168
|
+
network learns nothing from a join, and can't swap the installer. A used or expired invite is
|
|
169
|
+
worthless.
|
|
170
|
+
- **Machines can't impersonate each other.** Every message is signed by the machine that sent it,
|
|
171
|
+
and machines relaying a copy can't change it (they can read it: members are trusted). `natter remove <host>` takes a machine off the
|
|
172
|
+
network; members that were offline learn about it when they come back.
|
|
173
|
+
- **Members are trusted.** Any member can add machines, and every agent on a member machine can
|
|
174
|
+
message yours. Only add machines you control.
|
|
175
|
+
- **Prompt injection.** Messages come from other AI agents. nattermatic tells Claude to treat them
|
|
176
|
+
as requests from a colleague, not instructions from you, and to check with you before
|
|
177
|
+
anything destructive or sensitive.
|
|
178
|
+
|
|
179
|
+
See [SECURITY.md](SECURITY.md) for details and known limitations.
|
|
180
|
+
|
|
181
|
+
## Caveats
|
|
182
|
+
|
|
183
|
+
- Only interactive sessions are woken. `claude -p` runs and SDK sessions aren't, and nothing about
|
|
184
|
+
them changes: their messages wait for the `inbox` tool.
|
|
185
|
+
- A Codex session is woken from its first prompt on, when Codex runs nattermatic's hook. After you
|
|
186
|
+
close a Codex window, Codex's background server can keep the session open, and then answers its
|
|
187
|
+
messages by itself. Two Codex sessions in the same folder can't be told apart: only the newer one
|
|
188
|
+
is woken. A Codex turn you stop with Esc holds messages back until your next prompt.
|
|
189
|
+
- A woken session reads messages by itself, but stops at permission prompts as usual, so replying
|
|
190
|
+
may wait for you. Setup allows only the `inbox` and `peers` tools to run unattended.
|
|
191
|
+
- Senders see "pushed to … live session" when the session has been told, not when Claude has read
|
|
192
|
+
the message.
|
|
193
|
+
- A session idle for more than 20 days stops being woken until its next turn. So does one you
|
|
194
|
+
interrupt with Esc after it's woken and before it reads its messages; they're still there to read.
|
|
195
|
+
- A VPN that blocks local traffic makes your machines show as offline. Allow local network access
|
|
196
|
+
in its settings, or use Tailscale. Messages wait until the machines can reach each other.
|
|
197
|
+
- Over SSH on a Mac, Claude Code can't reach its login while the keychain is locked: run
|
|
198
|
+
`security unlock-keychain` first.
|
|
199
|
+
- Re-running `natter setup` keeps the machine on its network; pass `--new-network` to start a
|
|
200
|
+
new one.
|
|
201
|
+
- A machine's name is fixed when it joins. To rename it, run `natter remove <old name>` on a
|
|
202
|
+
machine that joined before it, then run a new invite line with `| sh -s -- --host <new name>` in
|
|
203
|
+
place of `| sh`. Messages already queued for its old name wait until they expire.
|
|
204
|
+
- A removed machine comes back with a new invite: it gets a new key, and its messages, delivered
|
|
205
|
+
or not, are dropped. Joining another network drops them too.
|
|
206
|
+
- A machine joins once, for one computer login. A second login on the same machine is refused,
|
|
207
|
+
because the machine's name is already a member. Several Claude accounts under one login are fine.
|
|
208
|
+
- Apple silicon Macs and Linux. Intel Macs aren't currently supported: the npm install runs there,
|
|
209
|
+
but macOS may stop it reaching other machines (see below), and there's no app for them yet.
|
|
210
|
+
- On Linux, `.local` names need mDNS (`avahi-daemon` and `libnss-mdns`); without it, use the IP
|
|
211
|
+
line the invite offers. Port 7777 must be open in any firewall between your machines.
|
|
212
|
+
- On macOS, nattermatic needs Local Network access to reach your other machines. The app asks for
|
|
213
|
+
it by name; if you clicked Don't Allow, turn **nattermatic** on under System Settings → Privacy &
|
|
214
|
+
Security → Local Network. An npm install on a Mac (`NATTERMATIC_APP=0`) runs as plain `node` at
|
|
215
|
+
login, which macOS may block without asking: `natter who` then shows machines as offline
|
|
216
|
+
with `EHOSTUNREACH`.
|
|
217
|
+
|
|
218
|
+
## Development
|
|
219
|
+
|
|
220
|
+
npm test # unit tests + multi-node end-to-end: live delivery, offline session, offline machine
|
|
221
|
+
# via holders, dedupe, poll mode, outsiders, forged and altered messages, strangers
|
|
222
|
+
# at stored addresses, relays, invites and joining, removal, big backlogs, node
|
|
223
|
+
# restart, IP/Tailscale aliases
|
|
224
|
+
|
|
225
|
+
The macOS app (nattermatic as a single signed executable, so macOS asks for Local Network access by
|
|
226
|
+
name; see `scripts/build-app.mjs`):
|
|
227
|
+
|
|
228
|
+
npm run build:app # dist/nattermatic-<version>-macos-<arch>.zip
|
|
229
|
+
npm run build:app -- --arch x64 --sign "Developer ID Application: …" --notarize <profile>
|
|
230
|
+
NATTERMATIC_TEST_EXE=dist/arm64/nattermatic.app/Contents/MacOS/nattermatic npm run test:e2e
|
|
6
231
|
|
|
7
|
-
|
|
232
|
+
Releases (signed and notarized app, GitHub Release, npm): see [docs/releasing.md](docs/releasing.md).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Model
|
|
4
|
+
|
|
5
|
+
Each machine has its own ECDSA P-256 key pair, created by `natter setup` and kept in
|
|
6
|
+
`~/.nattermatic/node.key` (readable only by you). A nattermatic network is a list of member keys
|
|
7
|
+
(the *roster*): a set of signed records saying "this key, called `laptop.local`, was added by that
|
|
8
|
+
member" or "this key was removed". Members exchange and merge these records; every member computes
|
|
9
|
+
the same membership from the same records.
|
|
10
|
+
|
|
11
|
+
- **Transport.** Connections between machines are TLS 1.3. (The installer download also accepts TLS
|
|
12
|
+
1.2, for macOS's curl; that's protected by the key pinned in the invite line.) The certificate a
|
|
13
|
+
node presents is a throwaway (a new key for each connection, or each second at the most), so
|
|
14
|
+
connecting to a node's port shows nothing that identifies the machine, unless an invite is open:
|
|
15
|
+
see *Known limitations*. Machines prove who they are inside the encrypted connection. The client first shows it
|
|
16
|
+
holds the network's discovery key; without it, other paths get a 404 (the join paths answer
|
|
17
|
+
separately, see *The open port is visible*). The server then signs the connection
|
|
18
|
+
with its machine key. The client checks that this is the machine the roster says should be there,
|
|
19
|
+
and only then signs the connection with its own key and sends its request. If anyone else answers
|
|
20
|
+
at the address, they get nothing but that first, anonymous step, which has a time limit however
|
|
21
|
+
slowly they answer. Both signatures cover that one TLS session, so they can't be replayed, or
|
|
22
|
+
relayed by someone sitting in the middle.
|
|
23
|
+
- **Addresses are only hints.** LAN beacons, gossiped addresses, configured names and the source
|
|
24
|
+
IPs of requests can all be wrong or stale (DHCP reuse, a laptop on another network, spoofed
|
|
25
|
+
name resolution). An address is used for a machine only once the machine there proves it holds
|
|
26
|
+
that machine's key, so a wrong address never receives a request. Someone relaying traffic to the real
|
|
27
|
+
machine sees only ciphertext.
|
|
28
|
+
- **Membership.** A node accepts machine-to-machine requests only from current members' keys.
|
|
29
|
+
Before a machine has proved its key, the only membership records it can submit are the ones that
|
|
30
|
+
add it and one snapshot, which only the founding machine can have signed. Any member can add a machine by inviting it. A record counts, and is kept and passed on,
|
|
31
|
+
only if its signature checks out against the signer it names. A roster stops taking new records
|
|
32
|
+
at 1 MB, apart from the founder's snapshots and removals of a current member by a machine that joined before it, one per
|
|
33
|
+
machine, which may be as big as a removal has any reason to be: an entry for each machine the
|
|
34
|
+
target really added, and otherwise no bigger than what it clears. So a roster always fits in the
|
|
35
|
+
one request (8 MB) that carries it between machines, and a member can't stay on by filling one.
|
|
36
|
+
- **Removal.** Machines are ranked in the order they joined, and a machine can remove only machines
|
|
37
|
+
that joined after it. The founding machine can remove anyone, and two machines can never remove
|
|
38
|
+
each other. Removing the remover doesn't bring a machine back, and removals made at the same time
|
|
39
|
+
all stand. A removed key can't come back on its own (the machine isn't told, and re-joins with an
|
|
40
|
+
invite and a new key), with the exceptions in *Known limitations*. A key made by a removed machine
|
|
41
|
+
never becomes the discovery key.
|
|
42
|
+
- **Snapshots.** `natter compact`, on the founding machine, signs a list of the current members
|
|
43
|
+
in joining order, with the discovery keys still in use and a new one. Any machine can check it
|
|
44
|
+
against the network's ID, and it replaces all the records before it. After a snapshot, an add dated
|
|
45
|
+
before it counts for nothing, nor does an older record signed by a machine that isn't in it. A removal
|
|
46
|
+
between two machines in it still counts if it's dated after that machine was added, so a removal
|
|
47
|
+
made at the same moment isn't lost, and one from before a machine was let back in doesn't apply.
|
|
48
|
+
- **A removed machine can't add machines.** A removal lists the removed machine's records the
|
|
49
|
+
remover had. Machines it added stay if every remover had seen the add. An add the remover hadn't
|
|
50
|
+
seen doesn't count, whatever date it carries, and that machine needs a new invite. Every member
|
|
51
|
+
computes the same membership from the same records, whatever order they arrived in.
|
|
52
|
+
- **Messages.** Each message is signed by the machine that sent it. A node accepts a message only
|
|
53
|
+
if the signer is a current member and is the machine named in the sender address, so holders
|
|
54
|
+
relaying copies can't alter messages and a member can't send as another machine. A sender address
|
|
55
|
+
must be exactly `name@host` as a node writes it, so nothing can be hidden in it. Messages are
|
|
56
|
+
encrypted between machines but not end to end: see *Known limitations*.
|
|
57
|
+
- **Joining.** `natter invite` makes a one-time token (stored hashed, expires after 24 hours)
|
|
58
|
+
and prints a `curl -k --pinnedpubkey "sha256//<key>" https://…` line. The key is an invite key,
|
|
59
|
+
which the inviting node presents only while it has invites open. curl's `-k` only skips
|
|
60
|
+
certificate-authority checks; `--pinnedpubkey` still makes curl refuse any other key, so the
|
|
61
|
+
installer, the package and the token can't be intercepted or replaced. The joining machine checks
|
|
62
|
+
the same key again before it sends its join request. The token is consumed by the join.
|
|
63
|
+
`natter invite --revoke` cancels unused tokens. Starting or joining another network deletes the
|
|
64
|
+
old network's invites and all its messages, and setup stops rather than start the node if it can't.
|
|
65
|
+
- **Discovery.** LAN beacons are encrypted under a discovery key that members share only over
|
|
66
|
+
their TLS connections, so other people on the network can't read machine names from them. The
|
|
67
|
+
discovery key is replaced whenever a machine is removed. A replaced key still opens other
|
|
68
|
+
members' gates, and gets beacons answered, for 14 days, so a machine that was offline during a
|
|
69
|
+
removal can catch up. After that, it needs a new invite.
|
|
70
|
+
- **Local API.** Sessions on the same machine talk to their node over TLS on 127.0.0.1, with the
|
|
71
|
+
same check: the node must prove it holds this machine's key, and so must the client. Only
|
|
72
|
+
processes that can read this machine's private key (your user) get in, and a program squatting
|
|
73
|
+
on the port is sent nothing. `/health` answers only them.
|
|
74
|
+
- **Prompt injection.** Messages come from other AI agents. The MCP adapter instructs Claude to
|
|
75
|
+
treat them as requests from a colleague — never as instructions from its user — and not to
|
|
76
|
+
take destructive or security-sensitive actions solely because a peer asked. Each message is framed
|
|
77
|
+
with a tag made for that read, so its text can't pass for another message from someone else. Nothing
|
|
78
|
+
else another machine writes reaches a session: a session name must be one a node could have made, and
|
|
79
|
+
a machine's error text is dropped for its status code. Only invite machines and agents you trust.
|
|
80
|
+
- **Waking a session.** Setup adds a Claude Code hook that waits in the background and wakes an idle
|
|
81
|
+
session when a message arrives. What it shows Claude is fixed text saying a message is waiting:
|
|
82
|
+
nothing another agent wrote comes through the hook. Claude reads the message with the `inbox` tool,
|
|
83
|
+
so it arrives as a tool result from a named sender. Setup lets `inbox` and `peers` run without a
|
|
84
|
+
prompt; `send` keeps the session's normal permission rules. A woken session is still acting on
|
|
85
|
+
another agent's request, with whatever permissions the session has, so take care with sessions
|
|
86
|
+
running in a mode that skips permission prompts.
|
|
87
|
+
- **Waking a Codex session.** Setup adds a SessionStart hook to Codex's config, which Codex runs only
|
|
88
|
+
once you've trusted it. The hook starts a process that waits for the session's messages and runs
|
|
89
|
+
`codex queue` with fixed text, which Codex shows as a prompt in the session: again, nothing another
|
|
90
|
+
agent wrote. Setup lets Codex run `inbox` and `peers` without asking. Codex's background server can
|
|
91
|
+
keep a session open after its window closes, and then acts on messages with no one watching.
|
|
92
|
+
|
|
93
|
+
## Known limitations
|
|
94
|
+
|
|
95
|
+
- **Members are trusted.** Any member can invite machines, and a machine can remove the ones that
|
|
96
|
+
joined after it. A compromised member could quietly add extra machines before you remove it.
|
|
97
|
+
`natter status` lists machines in joining order and shows which member added each one:
|
|
98
|
+
remove any you don't recognise, or start a new network with `natter setup --new-network`
|
|
99
|
+
and re-join your machines. One thing it won't show: a member can sign a second key under its own
|
|
100
|
+
name. That key never becomes a member, and it's dropped when the member is removed.
|
|
101
|
+
- **A member can fill a machine's message store.** A session can have 2,000 messages waiting, but a
|
|
102
|
+
member can write to made-up names, or to a session reading as fast as they arrive. A store stops at
|
|
103
|
+
1 GB, so the disk is safe, but then that machine takes no new messages from anyone until old ones
|
|
104
|
+
expire, even after the member is removed. Messages sent to it wait at their senders until then.
|
|
105
|
+
- **A member can undo a removal made by a machine that joined after it.** It signs a backdated add of
|
|
106
|
+
the removed key, dated before the remover joined, so that the remover no longer ranks above it, and
|
|
107
|
+
the machine is back under its old key. A removal by a machine that joined before that member stands.
|
|
108
|
+
If you suspect a member, remove it first, from the earliest machine you can. A snapshot ends this
|
|
109
|
+
for everything before it, because adds can't be dated before a snapshot.
|
|
110
|
+
- **A snapshot forgets removed keys.** A removed machine can then come back under its old key, but
|
|
111
|
+
only if a member adds it: with an invite, as any new machine would. Sending to it after that says
|
|
112
|
+
it isn't a machine on the network, not that it was removed.
|
|
113
|
+
- **A snapshot can drop a machine.** One added just before it, which the founding machine hadn't
|
|
114
|
+
heard of, isn't in it and needs a new invite. `natter compact` catches up first, and shows who
|
|
115
|
+
will be kept and who it couldn't reach.
|
|
116
|
+
- **A snapshot goes by what the founding machine knows.** If it hasn't heard of a removal yet, two
|
|
117
|
+
things can go against the rules above. A machine that was removed by a machine that's now gone
|
|
118
|
+
too comes back, as the removal counts for nothing without its signer. And a machine added by a
|
|
119
|
+
removed one stays, if the founding machine heard of the add first. So make a snapshot with your
|
|
120
|
+
machines online, and check the list `natter compact` shows.
|
|
121
|
+
- **A backdated removal can cut a machine off until someone reaches it.** A member can sign a removal
|
|
122
|
+
dated before a snapshot that's over 14 days old, which brings in a new key and leaves no grace period
|
|
123
|
+
for the snapshot's. A machine that hasn't heard is refused at the gate until another member reaches
|
|
124
|
+
it, and then it catches up.
|
|
125
|
+
- **Only the founding machine can make a snapshot.** If it's lost, the way out of a full member list
|
|
126
|
+
is a new network.
|
|
127
|
+
- **Messages aren't end-to-end encrypted.** While the recipient's machine is off, copies wait on
|
|
128
|
+
other members, which can read them until they're handed over. They can't alter them. Don't send
|
|
129
|
+
anything through nattermatic that you wouldn't want your other machines to hold.
|
|
130
|
+
- **The founding machine can't be removed.** Nothing joined before it. If it's lost or
|
|
131
|
+
compromised, start a new network. Likewise, a machine can only be removed from machines that
|
|
132
|
+
joined before it.
|
|
133
|
+
- **A removed machine's removals of later machines can still take effect.** If it signs one and
|
|
134
|
+
gets it to a member that hasn't heard of its removal yet, the removal stands, like any removal
|
|
135
|
+
made at the same moment. It can never remove a machine that joined before it, add one, or get
|
|
136
|
+
back in. The affected machines re-join with an invite.
|
|
137
|
+
- **A removed machine can still tell which member it is talking to, for 14 days.** It can pass the
|
|
138
|
+
gate with an old discovery key during the grace period, and learn which member is at an address
|
|
139
|
+
(which it knew already). It still can't send, receive or change the member list.
|
|
140
|
+
- **Two removals at the same moment can leave a removed machine holding the current discovery key.**
|
|
141
|
+
If one machine removes another that has already heard of a third's removal, the removed machine has
|
|
142
|
+
the key that removal brought in, until the next removal by a current member, or a snapshot, replaces
|
|
143
|
+
it. It can pass the gate as above, for that long.
|
|
144
|
+
- **A member can flood the roster.** Until you remove it, a compromised member can fill the roster
|
|
145
|
+
so that no new machine can join, and machines may disagree about the last records to arrive.
|
|
146
|
+
Removing it clears what it signed, apart from machines it really added and its removals of machines
|
|
147
|
+
that had joined. Those stay until the next snapshot, so a roster can stay full once the member has
|
|
148
|
+
gone: remove the machines it added, then run `natter compact` on the founding machine. While
|
|
149
|
+
a roster is full, two kinds of removal wait until there's room, so machines can disagree until
|
|
150
|
+
then: a second removal of a machine already removed, and one listing dozens of machines a full node
|
|
151
|
+
has never heard of, if what fills that node was signed by someone else. Remove that machine first.
|
|
152
|
+
`natter remove` says so if it's this machine's own roster that has no room.
|
|
153
|
+
- **Agents on a machine aren't separately authenticated.** Any process running as your user can
|
|
154
|
+
send as any session name on that machine.
|
|
155
|
+
- **Invites are credentials until used.** Anyone who gets an unused invite line within 24 hours can
|
|
156
|
+
join one machine with it. Treat it like a password until it's been used, or revoke it. On a
|
|
157
|
+
shared machine, other users can see the line's token in the process list for the few seconds the
|
|
158
|
+
first `curl` runs (the installer keeps it off command lines after that).
|
|
159
|
+
- **While an invite is open, its key is visible to anyone who connects to the port.** Someone could
|
|
160
|
+
use it to recognise the machine during that window (24 hours at most). Run `natter invite
|
|
161
|
+
--revoke` once you've added your machines. At other times, a node shows only throwaway keys.
|
|
162
|
+
- **The open port is visible.** Someone scanning a network can see that something is listening on
|
|
163
|
+
nattermatic's port (7777 by default), and the join paths answer in a way that shows it's nattermatic.
|
|
164
|
+
- **Looking up `.local` names is visible.** Connections themselves don't reveal a machine's name,
|
|
165
|
+
but a laptop trying to reach `desk.local` asks for it by name, by multicast, on whatever network
|
|
166
|
+
it's on. Someone there can see the names of your machines.
|
|
167
|
+
- **A removed machine keeps what it had.** It keeps its copy of the member list (machine names and
|
|
168
|
+
public keys; no secrets) and any messages it already received.
|
|
169
|
+
|
|
170
|
+
## Starting over
|
|
171
|
+
|
|
172
|
+
Run `natter setup --new-network` on one machine. It creates a new key and a new network with
|
|
173
|
+
that machine as its only member; join the other machines with fresh invites.
|
|
174
|
+
|
|
175
|
+
## Reporting a vulnerability
|
|
176
|
+
|
|
177
|
+
Please open a private security advisory on GitHub ("Security" tab → "Report a vulnerability")
|
|
178
|
+
rather than a public issue.
|
package/bin/natter.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import '../src/legacy-env.js'
|
|
3
|
+
import { loadConfig, VERSION } from '../src/config.js'
|
|
4
|
+
import { resolve, findCommand, mainHelp, commandHelp, suggest, completionScript } from '../src/cli.js'
|
|
5
|
+
|
|
6
|
+
// (not --no-warnings on the line above: env -S isn't everywhere.) node:sqlite warns when it's loaded
|
|
7
|
+
process.removeAllListeners('warning')
|
|
8
|
+
|
|
9
|
+
const [first, ...rest] = process.argv.slice(2)
|
|
10
|
+
const cmd = resolve(first)
|
|
11
|
+
// Only these are flags; anything else (a message that happens to start with "--") is an argument,
|
|
12
|
+
// and "--" ends the flags.
|
|
13
|
+
const VALUE_FLAGS = ['name', 'from', 'host', 'peer', 'join', 'pin', 'net', 'token', 'thread', 'codex', 'pids', 'cwd']
|
|
14
|
+
const BOOL_FLAGS = ['yes', 'force', 'no-service', 'no-claude', 'no-codex', 'no-alias', 'new-network', 'poll', 'channel', 'on-demand', 'revoke', 'list', 'check']
|
|
15
|
+
/** @type {Record<string, any>} */
|
|
16
|
+
const flags = {}
|
|
17
|
+
/** @type {string[]} */
|
|
18
|
+
const pos = []
|
|
19
|
+
for (let i = 0; i < rest.length; i++) {
|
|
20
|
+
const a = rest[i]
|
|
21
|
+
if (cmd === 'to' && pos.length) { pos.push(a); continue } // past the address it's the message, whatever it looks like
|
|
22
|
+
if (a === '--') { pos.push(...rest.slice(i + 1)); break }
|
|
23
|
+
const eq = a.indexOf('=')
|
|
24
|
+
const k = a.startsWith('--') ? a.slice(2, eq < 0 ? undefined : eq) : ''
|
|
25
|
+
if (VALUE_FLAGS.includes(k)) {
|
|
26
|
+
const val = eq >= 0 ? a.slice(eq + 1) : rest[++i]
|
|
27
|
+
if (val === undefined) throw usage(`--${k} needs a value`)
|
|
28
|
+
if (k === 'peer') (flags.peer ||= []).push(val)
|
|
29
|
+
else flags[k] = val
|
|
30
|
+
} else if (BOOL_FLAGS.includes(k) && eq < 0) flags[k] = true
|
|
31
|
+
else pos.push(a)
|
|
32
|
+
}
|
|
33
|
+
function usage(/** @type {string} */ m) { console.error(`natter: ${m}`); process.exit(2) }
|
|
34
|
+
/** @param {string} name */
|
|
35
|
+
function unknownCommand(name) {
|
|
36
|
+
const s = suggest(name)
|
|
37
|
+
usage(`unknown command '${name}'${s ? ` (did you mean ${s}?)` : ''}: see natter help`)
|
|
38
|
+
}
|
|
39
|
+
if (!findCommand(cmd)) unknownCommand(first)
|
|
40
|
+
// `natter <command> --help` shows that command's help instead of running it. Not for claude
|
|
41
|
+
// (its options go to Claude Code), and for send only in place of the address: after it, it's message text.
|
|
42
|
+
const helpAsked = cmd === 'to' ? ['--help', '-h'].includes(rest[0])
|
|
43
|
+
: cmd !== 'claude' && rest.slice(0, rest.includes('--') ? rest.indexOf('--') : undefined).some(a => a === '--help' || a === '-h')
|
|
44
|
+
if (helpAsked) {
|
|
45
|
+
const c = findCommand(cmd)
|
|
46
|
+
if (!c || c.hidden) unknownCommand(cmd)
|
|
47
|
+
else { console.log(commandHelp(c)); process.exit(0) }
|
|
48
|
+
}
|
|
49
|
+
// a mistyped option (e.g. `invite --lsit`) must not run the command as if it weren't there; send
|
|
50
|
+
// takes free text and claude passes everything on to Claude Code
|
|
51
|
+
if (!['to', 'claude'].includes(cmd)) { const bad = pos.find(a => a.startsWith('-')); if (bad) usage(`unknown option ${bad} (see natter ${cmd} --help)`) }
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
switch (cmd) {
|
|
55
|
+
case 'setup': {
|
|
56
|
+
const { setup } = await import('../src/setup.js')
|
|
57
|
+
if (pos.length) throw new Error('nattermatic no longer uses a shared network secret: to join a network, run the line printed by `natter invite` on a member')
|
|
58
|
+
return setup({ host: flags.host, peers: flags.peer || [], noService: flags['no-service'], noClaude: flags['no-claude'], noCodex: flags['no-codex'], noAlias: flags['no-alias'], newNetwork: flags['new-network'], join: flags.join, pin: flags.pin, net: flags.net, token: flags.token || process.env.NATTERMATIC_TOKEN })
|
|
59
|
+
}
|
|
60
|
+
case 'node': {
|
|
61
|
+
const { runNode } = await import('../src/node.js')
|
|
62
|
+
const { Local } = await import('../src/local.js')
|
|
63
|
+
const fs = await import('node:fs')
|
|
64
|
+
const path = await import('node:path')
|
|
65
|
+
const { homeDir } = await import('../src/config.js')
|
|
66
|
+
// If another nattermatic node already serves this port (e.g. one a session started), the service
|
|
67
|
+
// waits quietly and takes over when it exits, instead of crash-looping under launchd/systemd.
|
|
68
|
+
// A node started on demand by a session (--on-demand) just exits: one node is enough.
|
|
69
|
+
// Every attempt re-reads the config and honours `natter uninstall`, so a long-waiting
|
|
70
|
+
// standby never revives an uninstalled node or serves a config that setup has since replaced.
|
|
71
|
+
for (let waited = false; ;) {
|
|
72
|
+
if (fs.existsSync(path.join(homeDir(), 'disabled'))) { console.error('nattermatic is uninstalled on this machine; not starting the node'); return }
|
|
73
|
+
const cfg = loadConfig()
|
|
74
|
+
try { await runNode(cfg); return } catch (e) {
|
|
75
|
+
if (/** @type {NodeJS.ErrnoException} */ (e).code !== 'EADDRINUSE') throw e
|
|
76
|
+
const local = new Local(cfg)
|
|
77
|
+
const h = await local.health()
|
|
78
|
+
if (!h || 'foreign' in h) { console.error(`port ${cfg.port} is in use by something else`); process.exit(1) }
|
|
79
|
+
if (flags['on-demand']) { console.error(`another nattermatic node (pid ${h.pid}) is already running`); return }
|
|
80
|
+
if (!waited) { console.error(`another nattermatic node (pid ${h.pid}) is running; standing by`); waited = true }
|
|
81
|
+
await new Promise(r => setTimeout(r, 5000))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
case 'mcp': {
|
|
86
|
+
const { runAdapter } = await import('../src/mcp.js') // --check: loading it (and its dependencies) is the check
|
|
87
|
+
if (flags.check) return console.log('ok')
|
|
88
|
+
return runAdapter(loadConfig(), { name: flags.name, poll: !!flags.poll, channel: flags.channel ? true : undefined })
|
|
89
|
+
}
|
|
90
|
+
case 'wake': { // run by the Claude Code hook, not by people
|
|
91
|
+
const { runWake } = await import('../src/wake.js')
|
|
92
|
+
process.exit(await runWake())
|
|
93
|
+
}
|
|
94
|
+
case 'codex-hook': { // run by Codex's SessionStart hook
|
|
95
|
+
const { runCodexHook } = await import('../src/codex.js')
|
|
96
|
+
process.exit(await runCodexHook())
|
|
97
|
+
}
|
|
98
|
+
case 'codex-wake': { // started by codex-hook, in the background
|
|
99
|
+
const { runCodexWake } = await import('../src/codex.js')
|
|
100
|
+
process.exit(await runCodexWake({ thread: flags.thread, pids: flags.pids, cwd: flags.cwd, codex: flags.codex }))
|
|
101
|
+
}
|
|
102
|
+
case 'claude': {
|
|
103
|
+
const { launchClaude } = await import('../src/claude.js')
|
|
104
|
+
const { Local } = await import('../src/local.js')
|
|
105
|
+
return launchClaude(process.argv.slice(3), async () => new Local(loadConfig()).ensureNode())
|
|
106
|
+
}
|
|
107
|
+
case 'to': {
|
|
108
|
+
if (pos.length < 2) throw new Error('usage: natter to <name@host> <message>')
|
|
109
|
+
const local = await client()
|
|
110
|
+
const r = await local.req('POST', '/local/send', { from: flags.from || 'cli', to: pos[0], body: pos.slice(1).join(' ') })
|
|
111
|
+
return console.log(r.status)
|
|
112
|
+
}
|
|
113
|
+
case 'who': {
|
|
114
|
+
const p = await (await client()).req('GET', '/local/peers')
|
|
115
|
+
console.log(`${p.self} (this machine): ${p.local.join(', ') || '(no agents)'}`)
|
|
116
|
+
for (const r of p.remote) console.log(`${r.host}: ${r.online ? (r.sessions.join(', ') || '(no agents)') : `offline${r.error ? ` — ${r.error}` : ''}`}`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
case 'status':
|
|
120
|
+
return console.log(JSON.stringify(await (await client()).req('GET', '/local/status'), null, 2))
|
|
121
|
+
case 'invite': {
|
|
122
|
+
const local = await client()
|
|
123
|
+
if (pos.length) throw new Error('usage: natter invite [--list|--revoke]')
|
|
124
|
+
if (flags.revoke) return console.log(`revoked ${(await local.req('POST', '/local/invite/revoke')).revoked} unused invite(s)`)
|
|
125
|
+
if (flags.list) {
|
|
126
|
+
const { invites } = await local.req('GET', '/local/invites')
|
|
127
|
+
const { formatInvites } = await import('../src/setup.js')
|
|
128
|
+
return console.log(formatInvites(invites))
|
|
129
|
+
}
|
|
130
|
+
const { invitePrompt } = await import('../src/setup.js')
|
|
131
|
+
return console.log(invitePrompt(await local.req('POST', '/local/invite')))
|
|
132
|
+
}
|
|
133
|
+
case 'remove': {
|
|
134
|
+
if (pos.length !== 1) throw new Error('usage: natter remove <host>')
|
|
135
|
+
const r = await (await client()).req('POST', '/local/remove', { host: pos[0] })
|
|
136
|
+
return console.log(`removed ${r.removed}: it can no longer send to or receive from this network`)
|
|
137
|
+
}
|
|
138
|
+
case 'compact': {
|
|
139
|
+
if (pos.length) throw new Error('usage: natter compact [--yes]')
|
|
140
|
+
const r = await (await client()).req('POST', '/local/compact', { confirm: !!flags.yes, force: !!flags.force }, '', 150_000)
|
|
141
|
+
const off = r.offline.length ? [`Not reachable just now, so anything they've heard of is unknown here: ${r.offline.join(', ')}`] : []
|
|
142
|
+
if (flags.yes) return console.log([`member list compacted: ${r.before} bytes to ${r.bytes}. Members: ${r.members.join(', ')}`, ...off].join('\n'))
|
|
143
|
+
return console.log([
|
|
144
|
+
`A snapshot would keep these machines: ${r.members.join(', ')}`,
|
|
145
|
+
...off,
|
|
146
|
+
...(r.caughtUp ? [] : ['Not every machine could be checked in time: run this again before going ahead.']),
|
|
147
|
+
`The member list is ${r.bytes} bytes (limit ${r.limit}). A machine added that isn't listed above will need a new invite, and removed machines are forgotten.`,
|
|
148
|
+
'A removal this machine has not heard of can be undone by this, if the machine that made it is removed too: check any machine you removed from elsewhere is gone from the list above.',
|
|
149
|
+
'Run `natter compact --yes` to go ahead.',
|
|
150
|
+
].join('\n'))
|
|
151
|
+
}
|
|
152
|
+
case 'uninstall': {
|
|
153
|
+
const { uninstall } = await import('../src/setup.js')
|
|
154
|
+
return await uninstall()
|
|
155
|
+
}
|
|
156
|
+
case 'version': return console.log(VERSION)
|
|
157
|
+
case 'help': {
|
|
158
|
+
if (!pos.length) return console.log(mainHelp(VERSION))
|
|
159
|
+
const c = findCommand(resolve(pos[0]))
|
|
160
|
+
if (!c || c.hidden) return unknownCommand(pos[0])
|
|
161
|
+
return console.log(commandHelp(c))
|
|
162
|
+
}
|
|
163
|
+
case 'completion': {
|
|
164
|
+
if (pos.length !== 1) throw new Error('usage: natter completion <zsh | bash>')
|
|
165
|
+
return process.stdout.write(completionScript(pos[0]))
|
|
166
|
+
}
|
|
167
|
+
case '__complete': { // for the completion scripts: never starts a node
|
|
168
|
+
const { Local } = await import('../src/local.js')
|
|
169
|
+
let local
|
|
170
|
+
try { local = new Local(loadConfig()) } catch { return }
|
|
171
|
+
const h = await local.health()
|
|
172
|
+
if (!h || 'foreign' in h) return
|
|
173
|
+
const p = await local.req('GET', '/local/peers', undefined, '', 3000).catch(() => null)
|
|
174
|
+
if (!p) return
|
|
175
|
+
if (pos[0] === 'hosts') return console.log(p.remote.map((/** @type {any} */ r) => r.host).join('\n'))
|
|
176
|
+
const out = [...p.local.map((/** @type {string} */ n) => `${n}@${p.self}`),
|
|
177
|
+
...p.remote.flatMap((/** @type {any} */ r) => r.sessions.map((/** @type {string} */ n) => `${n}@${r.host}`))]
|
|
178
|
+
return out.length && console.log(out.join('\n'))
|
|
179
|
+
}
|
|
180
|
+
default: return unknownCommand(first)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function client() {
|
|
185
|
+
const { Local } = await import('../src/local.js')
|
|
186
|
+
const local = new Local(loadConfig())
|
|
187
|
+
await local.ensureNode()
|
|
188
|
+
return local
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
main().catch(e => { console.error(`natter: ${e instanceof Error ? e.message : e}`); process.exit(1) })
|