nattermatic 0.0.1 → 0.2.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/install.sh ADDED
@@ -0,0 +1,229 @@
1
+ #!/bin/sh
2
+ # nattermatic installer — wake-on-message networking for AI agents (Claude Code and others)
3
+ #
4
+ # New network: curl -fsSL https://nattermatic.com/install.sh | sh
5
+ # Join network: paste the one-time line printed by `natter invite` on a machine that's already set up
6
+ #
7
+ # Installs (no sudo): on Apple silicon Macs, nattermatic.app in ~/Applications; elsewhere (or if the
8
+ # app can't be fetched) a private Node.js runtime if a recent one isn't present and the nattermatic
9
+ # npm package. Then a background service, and nattermatic for every Claude Code account here.
10
+ set -e
11
+ #@@PRESET@@
12
+ # nattermatic was called agentlink until 0.2.0: AGENTLINK_* settings still count, unless the new name is set
13
+ for v in $(env | sed -n 's/^AGENTLINK_\([A-Z0-9_]*\)=.*/\1/p'); do
14
+ eval "[ -n \"\${NATTERMATIC_$v+x}\" ] || { NATTERMATIC_$v=\"\$AGENTLINK_$v\"; export NATTERMATIC_$v; }"
15
+ done
16
+ PKG="${NATTERMATIC_PKG:-nattermatic}"
17
+ NATTERMATIC_ORIG_PATH="$PATH"; export NATTERMATIC_ORIG_PATH
18
+ TMP=$(mktemp -d)
19
+ trap 'rm -rf "$TMP"' EXIT
20
+ trap 'exit 1' HUP INT TERM # dash doesn't run the EXIT trap for these by itself
21
+
22
+ # Rosetta's shells call an Apple silicon Mac x86_64
23
+ PLATFORM="$(uname -s)-$(uname -m)"
24
+ if [ "$PLATFORM" = Darwin-x86_64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = 1 ]; then
25
+ PLATFORM=Darwin-arm64
26
+ fi
27
+
28
+ # what nattermatic needs: found out before anything is changed
29
+ MIN_CLAUDE=2.1.318 # WAKE_MIN_CLAUDE in src/claude.js
30
+ check_prereqs() {
31
+ [ -n "${NATTERMATIC_SKIP_CHECKS:-}" ] && return 0
32
+ if [ "$PLATFORM" = Darwin-x86_64 ]; then
33
+ echo "nattermatic: Intel Macs aren't supported yet (macOS may stop it reaching your other machines)." >&2
34
+ echo "To install it anyway: NATTERMATIC_SKIP_CHECKS=1" >&2
35
+ exit 1
36
+ fi
37
+ C=$(command -v claude 2>/dev/null || true)
38
+ for f in "$HOME/.local/bin/claude" "$HOME/.claude/local/claude"; do
39
+ if [ -z "$C" ] && [ -x "$f" ]; then C=$f; fi
40
+ done
41
+ if [ -z "$C" ]; then
42
+ # run by Claude Code itself: it's there, just not on this shell's PATH
43
+ [ -n "${CLAUDECODE:-}" ] && return 0
44
+ echo "nattermatic: Claude Code isn't installed. Install it first: https://claude.com/claude-code" >&2
45
+ echo "(For another MCP agent, set NATTERMATIC_SKIP_CHECKS=1.)" >&2
46
+ exit 1
47
+ fi
48
+ V=$("$C" --version 2>/dev/null | sed -n 's/^\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1)
49
+ [ -n "$V" ] || return 0
50
+ OLD=$(printf '%s\n%s\n' "$V" "$MIN_CLAUDE" | awk -F. 'NR==1 { a=$1; b=$2; c=$3 } NR==2 { print (a<$1 || (a==$1 && (b<$2 || (b==$2 && c<$3)))) ? 1 : 0 }')
51
+ if [ "$OLD" = 1 ]; then
52
+ echo "nattermatic: needs Claude Code $MIN_CLAUDE or newer, and this is $V. Update it (claude update), then run this again." >&2
53
+ exit 1
54
+ fi
55
+ }
56
+ check_prereqs
57
+
58
+ # nattermatic's home holds this machine's private key, messages and logs: private to this user from the start
59
+ AL="${NATTERMATIC_HOME:-$HOME/.nattermatic}"
60
+ mkdir -p "$AL"
61
+ chmod 700 "$AL"
62
+
63
+ # The Apple Developer team that signs nattermatic.app: the app is installed only with its signature
64
+ TEAM="${NATTERMATIC_TEAM:-CZ63J37ULN}"
65
+ APPS="$HOME/Applications"
66
+ RELEASES=https://github.com/tombaldwin/nattermatic/releases/latest/download
67
+
68
+ # Fetch from the inviting machine, pinned to its invite key (-k only skips CA checks; curl still
69
+ # refuses any key but the pinned one). The URL holds the invite token, so it goes to curl in a
70
+ # private file, not on the command line (where `ps` would show it).
71
+ fetch_invite() {
72
+ printf 'url = "%s"\n' "$1" > "$TMP/curl.cfg"
73
+ if [ -n "${NATTERMATIC_PIN:-}" ]; then
74
+ curl -fsSL -k --pinnedpubkey "sha256//$NATTERMATIC_PIN" -K "$TMP/curl.cfg" -o "$2"
75
+ else
76
+ curl -fsSL -K "$TMP/curl.cfg" -o "$2"
77
+ fi
78
+ }
79
+
80
+ # nattermatic.app (Apple silicon): as a signed app, macOS asks for Local Network access by name and
81
+ # the permission covers nattermatic only. From the inviting machine when it runs the app, else from
82
+ # the latest GitHub release. Returns 1 if no app is available (the npm install is used instead).
83
+ # This function runs as an if condition, where set -e is off, so each step says what happens if it fails.
84
+ APP_EXE="$APPS/nattermatic.app/Contents/MacOS/nattermatic"
85
+ no_app() {
86
+ echo "nattermatic: could not download nattermatic.app ($1)."
87
+ if [ -x "$APP_EXE" ]; then
88
+ echo "Keeping the one already in $APPS."
89
+ return 0
90
+ fi
91
+ echo "Installing with npm instead: macOS may stop that reaching your other machines. Run this again later for the app."
92
+ return 1
93
+ }
94
+ # the command is natter; nattermatic and agentlink (its name before the rename) run it too
95
+ CMDS="natter nattermatic agentlink"
96
+ link_app() {
97
+ mkdir -p "$AL/bin"
98
+ for c in $CMDS; do rm -f "$AL/bin/$c"; ln -s "$APP_EXE" "$AL/bin/$c"; done
99
+ }
100
+ install_app() {
101
+ [ "$PLATFORM" = Darwin-arm64 ] && [ "${NATTERMATIC_APP:-1}" != 0 ] || return 1
102
+ if [ -n "${NATTERMATIC_APP_ZIP:-}" ]; then
103
+ # a local build: asked for by name, so failing to read it is an error, not a reason to use npm
104
+ cp "$NATTERMATIC_APP_ZIP" "$TMP/app.zip" ||
105
+ { echo "nattermatic: cannot read $NATTERMATIC_APP_ZIP (on macOS, the terminal may not be allowed into that folder: move the file elsewhere, e.g. your home folder) — aborting"; exit 1; }
106
+ elif [ -n "${NATTERMATIC_APP_URL:-}" ]; then
107
+ echo "Fetching nattermatic.app from the inviting machine..."
108
+ if ! fetch_invite "$NATTERMATIC_APP_URL" "$TMP/app.zip"; then
109
+ no_app "the inviting machine didn't send it" && link_app && return 0
110
+ return 1
111
+ fi
112
+ else
113
+ if ! curl -fsSL "$RELEASES/nattermatic-macos-arm64.zip" -o "$TMP/app.zip" ||
114
+ ! curl -fsSL "$RELEASES/nattermatic-macos-arm64.zip.sha256" -o "$TMP/app.sha256"; then
115
+ no_app "from the latest release" && link_app && return 0
116
+ return 1
117
+ fi
118
+ [ "$(awk '{print $1}' "$TMP/app.sha256")" = "$(shasum -a 256 "$TMP/app.zip" | awk '{print $1}')" ] ||
119
+ { echo "nattermatic: the app download failed its checksum — aborting"; exit 1; }
120
+ fi
121
+ # a damaged or foreign app is an error, not a reason to fall back
122
+ mkdir -p "$TMP/app"
123
+ ditto -x -k "$TMP/app.zip" "$TMP/app" || { echo "nattermatic: the app archive is damaged — aborting"; exit 1; }
124
+ A="$TMP/app/nattermatic.app"
125
+ # the requirement checks the certificate Apple issued: the team a signature merely states can be anything
126
+ codesign --verify --strict -R="anchor apple generic and certificate leaf[subject.OU] = \"$TEAM\"" "$A" 2>/dev/null ||
127
+ { echo "nattermatic: the app isn't validly signed by nattermatic's team ($TEAM) — aborting"; exit 1; }
128
+ mkdir -p "$APPS"
129
+ # beside the old app first, then swapped by renaming: whatever fails, a whole app is left in place.
130
+ # (mv into a directory that's still there would put it inside, so nothing here may be left over.)
131
+ NEW="$APPS/.nattermatic.app.new"
132
+ OLD="$APPS/.nattermatic.app.old"
133
+ rm -rf "$NEW" "$OLD"
134
+ if [ -e "$NEW" ] || [ -e "$OLD" ]; then echo "nattermatic: could not clear $NEW and $OLD — aborting"; exit 1; fi
135
+ ditto "$A" "$NEW" || { echo "nattermatic: could not copy the app to $APPS — aborting"; exit 1; }
136
+ if [ -e "$APPS/nattermatic.app" ]; then
137
+ mv "$APPS/nattermatic.app" "$OLD" || { echo "nattermatic: could not replace the app in $APPS — aborting"; exit 1; }
138
+ fi
139
+ if ! mv "$NEW" "$APPS/nattermatic.app"; then
140
+ [ ! -e "$OLD" ] || mv "$OLD" "$APPS/nattermatic.app"
141
+ echo "nattermatic: could not put the app in $APPS — aborting"
142
+ exit 1
143
+ fi
144
+ rm -rf "$OLD" || true
145
+ link_app
146
+ echo "Installed nattermatic.app in $APPS"
147
+ }
148
+
149
+ if install_app; then
150
+ NATTERMATIC_BIN_DIR="$AL/bin" "$AL/bin/natter" setup "$@"
151
+ exit
152
+ fi
153
+
154
+ node_ok() {
155
+ [ "${NATTERMATIC_PRIVATE_NODE:-}" != 1 ] &&
156
+ command -v node >/dev/null 2>&1 &&
157
+ node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>23||(a===23&&b>=4)||(a===22&&b>=13)?0:1)' 2>/dev/null
158
+ }
159
+
160
+ PRIV="$AL/node"
161
+ USE_PRIV=0
162
+ if node_ok; then
163
+ :
164
+ elif [ -x "$PRIV/bin/node" ] && "$PRIV/bin/node" -e 0 2>/dev/null; then
165
+ export PATH="$PRIV/bin:$PATH"; USE_PRIV=1
166
+ else
167
+ case "$PLATFORM" in
168
+ Darwin-arm64) P=darwin-arm64 ;; Darwin-x86_64) P=darwin-x64 ;;
169
+ Linux-x86_64) P=linux-x64 ;; Linux-aarch64|Linux-arm64) P=linux-arm64 ;;
170
+ *) echo "nattermatic: no prebuilt Node.js for $(uname -sm); install Node 22.13+ and run this again."; exit 1 ;;
171
+ esac
172
+ # nodejs.org's Linux builds need glibc
173
+ if [ "$(uname -s)" = Linux ] && ls /lib/ld-musl-* >/dev/null 2>&1; then
174
+ echo "nattermatic: no prebuilt Node.js for this Linux (it uses musl): install Node 22.13+ with your package manager (on Alpine: apk add nodejs npm) and run this again."
175
+ exit 1
176
+ fi
177
+ echo "Installing a private Node.js runtime in $PRIV (your system Node, if any, is untouched)..."
178
+ DIST=https://nodejs.org/dist/latest-v24.x
179
+ curl -fsSL "$DIST/SHASUMS256.txt" -o "$TMP/SUMS"
180
+ FILE=$(awk '{print $2}' "$TMP/SUMS" | grep -E "^node-v[0-9.]+-$P\.tar\.gz$" | head -1)
181
+ [ -n "$FILE" ] || { echo "nattermatic: could not find a Node.js download for $P"; exit 1; }
182
+ curl -fsSL "$DIST/$FILE" -o "$TMP/$FILE"
183
+ WANT=$(awk -v f="$FILE" '$2==f {print $1}' "$TMP/SUMS")
184
+ GOT=$( (shasum -a 256 "$TMP/$FILE" 2>/dev/null || sha256sum "$TMP/$FILE") | awk '{print $1}')
185
+ [ "$WANT" = "$GOT" ] || { echo "nattermatic: Node.js download failed its checksum — aborting"; exit 1; }
186
+ rm -rf "$PRIV"; mkdir -p "$PRIV"
187
+ tar xzf "$TMP/$FILE" -C "$PRIV" --strip-components=1
188
+ export PATH="$PRIV/bin:$PATH"; USE_PRIV=1
189
+ fi
190
+
191
+ # joining via an invite: the package comes from the inviting machine
192
+ case "$PKG" in
193
+ https://*) fetch_invite "$PKG" "$TMP/nattermatic.tgz"; PKG="$TMP/nattermatic.tgz" ;;
194
+ esac
195
+
196
+ # npm install into a prefix, showing npm's own error output if it fails
197
+ npm_install() {
198
+ npm install -g --no-fund --no-audit --loglevel=error "$@" "$PKG" >"$TMP/npm.log" 2>&1 && return 0
199
+ return 1
200
+ }
201
+ npm_failed() { cat "$TMP/npm.log" >&2; echo "nattermatic: installing $PKG with npm failed (npm's output is above)" >&2; exit 1; }
202
+
203
+ echo "Installing nattermatic..."
204
+ if [ "$USE_PRIV" = 1 ]; then
205
+ # Keep the private runtime off the user's PATH (it would shadow their own node/npm): install the
206
+ # package in its own prefix and put a single wrapper, run by the private node, in $AL/bin.
207
+ npm_install --prefix "$AL/pkg" || npm_failed
208
+ JS=$(node -e 'console.log(require("fs").realpathSync(process.argv[1]))' "$AL/pkg/bin/natter")
209
+ BINDIR="$AL/bin"
210
+ mkdir -p "$BINDIR"
211
+ q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
212
+ for c in $CMDS; do
213
+ rm -f "$BINDIR/$c" # it may be the link to an earlier app: writing through it would replace the app
214
+ # shellcheck disable=SC2016 # "$@" is meant literally: it's the wrapper's own arguments
215
+ printf '#!/bin/sh\nexec %s --no-warnings %s "$@"\n' "$(q "$PRIV/bin/node")" "$(q "$JS")" > "$BINDIR/$c"
216
+ chmod 755 "$BINDIR/$c"
217
+ done
218
+ elif { npm uninstall -g --loglevel=error @tombaldwin/agentlink >/dev/null 2>&1; npm_install; }; then # agentlink: its name before the rename
219
+ BINDIR="$(npm prefix -g)/bin"
220
+ elif grep -qE 'EACCES|EPERM' "$TMP/npm.log"; then
221
+ echo "(global npm install not permitted; installing to ~/.local)"
222
+ npm uninstall -g --prefix "$HOME/.local" --loglevel=error @tombaldwin/agentlink >/dev/null 2>&1 || true
223
+ npm_install --prefix "$HOME/.local" || npm_failed
224
+ BINDIR="$HOME/.local/bin"
225
+ else
226
+ npm_failed
227
+ fi
228
+
229
+ NATTERMATIC_BIN_DIR="$BINDIR" "$BINDIR/natter" setup "$@"
package/package.json CHANGED
@@ -1,10 +1,66 @@
1
1
  {
2
2
  "name": "nattermatic",
3
- "version": "0.0.1",
4
- "description": "The coming new name for agentlink: AI agents on different Claude accounts and machines message each other. Install @tombaldwin/agentlink for now.",
5
- "homepage": "https://github.com/tombaldwin/agentlink",
6
- "repository": { "type": "git", "url": "git+https://github.com/tombaldwin/agentlink.git" },
3
+ "version": "0.2.0",
4
+ "description": "Let AI agents on different Claude accounts and machines natter to each other — incoming messages wake idle Claude Code and Codex sessions",
5
+ "mcpName": "io.github.tombaldwin/nattermatic",
6
+ "keywords": [
7
+ "claude",
8
+ "claude-code",
9
+ "mcp",
10
+ "agents",
11
+ "multi-agent",
12
+ "channels",
13
+ "messaging",
14
+ "lan"
15
+ ],
16
+ "type": "module",
17
+ "bin": {
18
+ "natter": "bin/natter.js",
19
+ "nattermatic": "bin/natter.js",
20
+ "agentlink": "bin/natter.js"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "src",
25
+ "install.sh",
26
+ "README.md",
27
+ "LICENSE",
28
+ "SECURITY.md"
29
+ ],
30
+ "engines": {
31
+ "node": "^22.13.0 || >=23.4.0"
32
+ },
33
+ "os": [
34
+ "darwin",
35
+ "linux"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/tombaldwin/nattermatic.git"
40
+ },
41
+ "homepage": "https://nattermatic.com",
42
+ "bugs": {
43
+ "url": "https://github.com/tombaldwin/nattermatic/issues"
44
+ },
45
+ "scripts": {
46
+ "test": "npm run test:unit && npm run test:e2e",
47
+ "test:unit": "node --no-warnings --test test/unit/*.test.js",
48
+ "test:e2e": "node --no-warnings test/e2e.js",
49
+ "typecheck": "tsc -p .",
50
+ "build:app": "node scripts/build-app.mjs"
51
+ },
52
+ "dependencies": {
53
+ "@modelcontextprotocol/sdk": "^1.32.2"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.20.5",
57
+ "esbuild": "^0.28.2",
58
+ "postject": "^1.0.0-alpha.6",
59
+ "typescript": "^7.0.2"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
7
64
  "author": "Tom Baldwin",
8
- "license": "MIT",
9
- "files": ["README.md"]
65
+ "license": "MIT"
10
66
  }
package/src/cert.js ADDED
@@ -0,0 +1,86 @@
1
+ // Machine identity: an ECDSA P-256 key pair and a minimal self-signed X.509 certificate for it.
2
+ // Node can sign but not build certificates, so this encodes the one fixed certificate shape we need.
3
+ // P-256 rather than Ed25519: stock macOS curl (LibreSSL) can't do TLS with Ed25519 certificates.
4
+ import crypto from 'node:crypto'
5
+
6
+ /** @param {number} n */
7
+ const derLen = n => {
8
+ if (n < 0x80) return Buffer.from([n])
9
+ /** @type {number[]} */
10
+ const b = []
11
+ while (n) { b.unshift(n & 0xff); n >>= 8 }
12
+ return Buffer.from([0x80 | b.length, ...b])
13
+ }
14
+ /** @param {number} tag @param {Buffer[]} parts */
15
+ const tlv = (tag, ...parts) => { const body = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), derLen(body.length), body]) }
16
+ /** @param {Buffer[]} p */
17
+ const seq = (...p) => tlv(0x30, ...p)
18
+ /** @param {Buffer[]} p */
19
+ const set = (...p) => tlv(0x31, ...p)
20
+ /** @param {string} s */
21
+ function oid(s) {
22
+ const a = s.split('.').map(Number), out = [a[0] * 40 + a[1]]
23
+ for (const v of a.slice(2)) {
24
+ const b = [v & 0x7f]
25
+ for (let x = v >>> 7; x; x >>>= 7) b.unshift(0x80 | (x & 0x7f))
26
+ out.push(...b)
27
+ }
28
+ return tlv(0x06, Buffer.from(out))
29
+ }
30
+ /** @param {Buffer} buf */
31
+ const int = buf => tlv(0x02, buf[0] & 0x80 ? Buffer.concat([Buffer.from([0]), buf]) : buf)
32
+ /** @param {Date} d */
33
+ const utcTime = d => tlv(0x17, Buffer.from(d.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z'))
34
+
35
+ /**
36
+ * A self-signed certificate for `privateKey`. Anyone can open a TLS connection to a node, so the
37
+ * certificate must not tell them the machine's name.
38
+ * @param {crypto.KeyObject} privateKey
39
+ * @returns {string} PEM
40
+ */
41
+ export function selfSignedCert(privateKey) {
42
+ const alg = seq(oid('1.2.840.10045.4.3.2')) // ecdsa-with-SHA256
43
+ const spki = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
44
+ // unique per key (TLS picks a trusted certificate by subject when several are trusted at once),
45
+ // derived from the key, never from the machine's name
46
+ const cn = `agentlink-${crypto.createHash('sha256').update(spki).digest('hex').slice(0, 16)}`
47
+ const name = seq(set(seq(oid('2.5.4.3'), tlv(0x0c, Buffer.from(cn)))))
48
+ const serial = crypto.randomBytes(16)
49
+ serial[0] = (serial[0] & 0x7f) | 0x01
50
+ const basicConstraints = seq(oid('2.5.29.19'), tlv(0x01, Buffer.from([0xff])), tlv(0x04, seq()))
51
+ const tbs = seq(
52
+ tlv(0xa0, int(Buffer.from([2]))), // v3
53
+ int(serial), alg, name,
54
+ seq(utcTime(new Date(Date.now() - 86400_000)), tlv(0x18, Buffer.from('99991231235959Z'))), // no expiry
55
+ name, spki,
56
+ tlv(0xa3, seq(basicConstraints)))
57
+ const sig = crypto.sign('sha256', tbs, privateKey)
58
+ const der = seq(tbs, alg, tlv(0x03, Buffer.from([0]), sig))
59
+ return `-----BEGIN CERTIFICATE-----\n${(der.toString('base64').match(/.{1,64}/g) || []).join('\n')}\n-----END CERTIFICATE-----\n`
60
+ }
61
+
62
+ /**
63
+ * A machine's identity: sha256 of its public key (SPKI DER), base64. The same format curl's
64
+ * --pinnedpubkey takes ("sha256//<fp>").
65
+ * @param {crypto.KeyObject | crypto.X509Certificate | string} k a key, a certificate, or a PEM of either
66
+ */
67
+ export function fingerprint(k) {
68
+ let pub
69
+ if (k instanceof crypto.X509Certificate) pub = k.publicKey
70
+ else if (typeof k === 'string' && k.includes('CERTIFICATE')) pub = new crypto.X509Certificate(k).publicKey
71
+ else pub = crypto.createPublicKey(k)
72
+ return crypto.createHash('sha256').update(pub.export({ type: 'spki', format: 'der' })).digest('base64')
73
+ }
74
+
75
+ /** A short form of a fingerprint for display. @param {string} fp */
76
+ export const shortFp = fp => fp.replace(/[^A-Za-z0-9]/g, '').slice(0, 10)
77
+
78
+ /** @returns {import('./types.js').Identity} */
79
+ export function newIdentity() {
80
+ const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' })
81
+ const cert = selfSignedCert(privateKey)
82
+ return { key: /** @type {string} */ (privateKey.export({ type: 'pkcs8', format: 'pem' })), cert, fp: fingerprint(privateKey) }
83
+ }
84
+
85
+ /** @param {string} pem */
86
+ export const certDer = pem => new crypto.X509Certificate(pem).raw.toString('base64')