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/src/claude.js ADDED
@@ -0,0 +1,411 @@
1
+ // Claude Code integration for machines with several Claude accounts (CLAUDE_CONFIG_DIR).
2
+ import os from 'node:os'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { spawn, spawnSync } from 'node:child_process'
6
+ import { cmpVersion, ensureHomeDir, errMsg, homeDir, shQuote as sq, writeAtomic } from './config.js'
7
+
8
+ import { SEA, BIN } from './self.js'
9
+ import { oldHome, renamedRule } from './legacy.js'
10
+ export { BIN }
11
+ const HOME = os.homedir()
12
+ const BEGIN = '# >>> nattermatic >>> (managed by `natter setup`; re-run it to refresh)'
13
+ const END = '# <<< nattermatic <<<'
14
+ // the block as agentlink wrote it, before the rename: read as this one
15
+ const OLD_BEGIN = '# >>> agentlink >>> (managed by `agentlink setup`; re-run it to refresh)', OLD_END = '# <<< agentlink <<<'
16
+ const renamedBlock = (/** @type {string} */ s) => s.split('\n').map(l => l === OLD_BEGIN ? BEGIN : l === OLD_END ? END : l).join('\n')
17
+ // one token: the option is variadic, so a separate value would also swallow a following prompt
18
+ const CHANNEL_FLAGS = ['--dangerously-load-development-channels=server:nattermatic']
19
+ // claude subcommands that must not get the channel flags
20
+ const SUBCOMMANDS = new Set(['mcp', 'config', 'doctor', 'update', 'upgrade', 'install', 'migrate-installer',
21
+ 'setup-token', 'plugin', 'plugins', 'agents', 'auth', 'login', 'logout', '-v', '--version', '-h', '--help'])
22
+
23
+ /**
24
+ * A node path that survives upgrades (e.g. /opt/homebrew/bin/node rather than .../Cellar/node/23.6.0/bin/node)
25
+ * and reboots: fnm's name for node on Linux is a link made for one shell, in a directory that goes
26
+ * with it. If an upgrade has already deleted the node running this, any that's there will do.
27
+ */
28
+ export function nodePath() {
29
+ const real = (/** @type {string} */ p) => { try { return fs.realpathSync(p) } catch { return null } }
30
+ const me = real(process.execPath)
31
+ const cands = /** @type {string[]} */ ([which('node'), '/opt/homebrew/bin/node', '/usr/local/bin/node'].filter(c => c !== null && !/\/fnm_multishells\/|^\/run\//.test(c)))
32
+ return cands.find(c => me ? real(c) === me : real(c)) || process.execPath
33
+ }
34
+ /** The command that runs nattermatic: node and bin/natter.js, or the single executable. */
35
+ export const selfCmd = () => SEA ? [process.execPath] : [nodePath(), '--no-warnings', BIN]
36
+ export const adapterCmd = () => [...selfCmd(), 'mcp']
37
+
38
+ /**
39
+ * Can Claude Code actually start the adapter it is registered with? Runs the registered command
40
+ * with --check (which loads the adapter and its dependencies, then exits). Without this, a broken
41
+ * install (say, a git clone without \`npm install\`) only shows up as a session that silently has
42
+ * no nattermatic. Returns null if it starts, otherwise why not.
43
+ * @returns {string | null}
44
+ */
45
+ export function checkAdapter() {
46
+ const [cmd, ...args] = adapterCmd()
47
+ const r = spawnSync(cmd, [...args, '--check'], { encoding: 'utf8', timeout: 20_000 })
48
+ if (r.status === 0) return null
49
+ const why = (r.error ? errMsg(r.error) : (r.stderr || r.stdout || `exit ${r.status}`)).trim().split('\n').filter(Boolean).slice(-2).join(' ')
50
+ const root = path.dirname(path.dirname(BIN))
51
+ const clone = !SEA && fs.existsSync(path.join(root, '.git')) && !fs.existsSync(path.join(root, 'node_modules'))
52
+ return clone ? `${why} — this is a git clone without its dependencies: run \`npm install\` in ${root}, then \`natter setup\` again` : why
53
+ }
54
+ /** Double-quote for sh, escaping the characters that stay special inside double quotes. */
55
+ const dq = (/** @type {unknown} */ v) => `"${String(v).replace(/[\\"$`]/g, '\\$&')}"`
56
+
57
+ /** Full path of `cmd` on PATH, or null. (Not `which`: some systems don't have it.) @param {string} cmd */
58
+ export function which(cmd) {
59
+ for (const dir of (process.env.PATH || '').split(':').filter(Boolean)) {
60
+ const f = path.resolve(dir, cmd)
61
+ try { fs.accessSync(f, fs.constants.X_OK); if (fs.statSync(f).isFile()) return f } catch {}
62
+ }
63
+ return null
64
+ }
65
+
66
+ /** The claude executable: on PATH, or in one of Claude Code's per-user installs, which a shell that
67
+ * didn't load the user's profile can't see (install.sh checks the same places). */
68
+ export function claudeBin() {
69
+ const onPath = which('claude')
70
+ if (onPath) return onPath
71
+ for (const f of [path.join(HOME, '.local', 'bin', 'claude'), path.join(HOME, '.claude', 'local', 'claude')]) {
72
+ try { fs.accessSync(f, fs.constants.X_OK); return f } catch {}
73
+ }
74
+ return null
75
+ }
76
+
77
+ /** Where zsh keeps its files. */
78
+ const zshDir = () => process.env.ZDOTDIR || HOME
79
+ function rcFiles() {
80
+ const files = ['.zshrc', '.zprofile', '.bashrc', '.bash_profile', '.bash_login', '.profile'].map(f => path.join(HOME, f))
81
+ return [...new Set([path.join(zshDir(), '.zshrc'), path.join(zshDir(), '.zprofile'), ...files])].filter(f => fs.existsSync(f))
82
+ }
83
+ export function primaryRc() {
84
+ const shell = path.basename(process.env.SHELL || '')
85
+ if (shell === 'zsh') return path.join(zshDir(), '.zshrc')
86
+ if (shell === 'bash' && process.platform !== 'darwin') return path.join(HOME, '.bashrc')
87
+ // bash reads the first of these it finds at login, as Terminal starts it: a new file would hide the others
88
+ if (shell === 'bash') {
89
+ const files = ['.bash_profile', '.bash_login', '.profile'].map(f => path.join(HOME, f))
90
+ return files.find(f => fs.existsSync(f)) || files[0]
91
+ }
92
+ return path.join(HOME, '.profile')
93
+ }
94
+
95
+ /** A path as the shell would see it, or as far as we can tell: a variable we don't have stays as it is. @param {string} p */
96
+ const expand = p => p.replace(/^~(?=\/|$)/, HOME).replace(/\$(?:\{(\w+)\}|(\w+))/g, (m, a, b) => (a || b) === 'HOME' ? HOME : process.env[a || b] ?? m)
97
+ const escRe = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
98
+ // never across another BEGIN: a block that lost its END mustn't take the user's lines with it
99
+ const BLOCK = `${escRe(BEGIN)}(?:(?!${escRe(BEGIN)})[\\s\\S])*?${escRe(END)}`
100
+ const stripBlock = (/** @type {string} */ s) => s.replace(new RegExp(`\\n?${BLOCK}\\n?`, 'g'), '\n').split('\n').filter(l => l !== BEGIN && l !== END).join('\n')
101
+
102
+ /** Shell aliases that run claude with a CLAUDE_CONFIG_DIR, e.g. alias claude2='CLAUDE_CONFIG_DIR="$HOME/.claude2" claude' */
103
+ function accountAliases() {
104
+ const out = []
105
+ for (const rc of rcFiles()) {
106
+ let text
107
+ try { text = stripBlock(fs.readFileSync(rc, 'utf8')) } catch { continue } // one we can't read has nothing to tell us
108
+ for (const line of text.split('\n')) {
109
+ const m = line.match(/^\s*alias\s+([\w.-]+)=(['"])(.*)\2\s*$/)
110
+ if (!m) continue
111
+ // the alias's value as the shell sees it: undo the quoting it was written with
112
+ const value = m[2] === "'" ? m[3].replace(/'\\''/g, "'") : m[3].replace(/\\([\\"$`])/g, '$1')
113
+ if (!/(^|\s)claude(\s|$)/.test(value)) continue
114
+ const d = value.match(/CLAUDE_CONFIG_DIR=(?:"([^"]+)"|'([^']+)'|([^\s"']+))/)
115
+ const dir = d && (d[1] || d[2] || d[3])
116
+ if (!dir || /\\/.test(dir)) continue
117
+ // a variable this process doesn't have: the path can't be known, and a guess would make one up
118
+ const full = expand(dir)
119
+ if (path.isAbsolute(full) && !full.includes('$')) out.push({ name: m[1], value, dir: path.resolve(full) })
120
+ }
121
+ }
122
+ return out
123
+ }
124
+
125
+ /**
126
+ * All Claude accounts on this machine: the default one plus every CLAUDE_CONFIG_DIR we can find
127
+ * (directories ~/.claude* holding a .claude.json, and dirs named in shell aliases).
128
+ * Returns [{ dir: ''|absPath, alias: 'claude'|'claude2'|..., aliasValue? }]
129
+ */
130
+ export function claudeAccounts() {
131
+ /** @type {Map<string, import('./types.js').ClaudeAccount>} */
132
+ const byDir = new Map([['', { dir: '', alias: 'claude' }]])
133
+ for (const e of fs.readdirSync(HOME)) {
134
+ if (!e.startsWith('.claude') || e === '.claude') continue
135
+ const d = path.join(HOME, e)
136
+ try { if (fs.statSync(d).isDirectory() && fs.existsSync(path.join(d, '.claude.json'))) byDir.set(d, { dir: d, alias: e.slice(1) }) } catch {}
137
+ }
138
+ for (const a of accountAliases()) byDir.set(a.dir, { dir: a.dir, alias: a.name, aliasValue: a.value })
139
+ // the account this shell is using, wherever it lives
140
+ if (process.env.CLAUDE_CONFIG_DIR) {
141
+ const d = path.resolve(expand(process.env.CLAUDE_CONFIG_DIR))
142
+ if (!byDir.has(d)) byDir.set(d, { dir: d, alias: `claude-${path.basename(d).replace(/^\./, '')}` })
143
+ }
144
+ // accounts enabled by `natter claude` under a CLAUDE_CONFIG_DIR found nowhere above
145
+ for (const d of registeredDirs()) if (!byDir.has(d) && fs.existsSync(d)) byDir.set(d, { dir: d, alias: `claude-${path.basename(d).replace(/^\./, '')}` })
146
+ return [...byDir.values()]
147
+ }
148
+
149
+ const accountsFile = () => path.join(homeDir(), 'accounts.json')
150
+ /** Account dirs nattermatic has registered with (so uninstall can find them all). @returns {string[]} */
151
+ function registeredDirs() {
152
+ try { const v = JSON.parse(fs.readFileSync(accountsFile(), 'utf8')); return Array.isArray(v) ? v.filter(d => typeof d === 'string' && d) : [] } catch { return [] }
153
+ }
154
+ /** @param {string} dir */
155
+ function rememberDir(dir) {
156
+ if (!dir) return
157
+ const dirs = registeredDirs()
158
+ if (dirs.includes(dir)) return
159
+ try { writeAtomic(accountsFile(), JSON.stringify([...dirs, dir]) + '\n', 0o600) } catch {}
160
+ }
161
+
162
+ /** Claude's per-account config file ('' = default account). @param {string} dir */
163
+ const configFile = dir => dir ? path.join(dir, '.claude.json') : path.join(HOME, '.claude.json')
164
+
165
+ /** @param {string} dir */
166
+ export function isRegistered(dir) {
167
+ try {
168
+ const servers = JSON.parse(fs.readFileSync(configFile(dir), 'utf8')).mcpServers
169
+ const s = servers?.nattermatic
170
+ if (servers?.agentlink) return false // still registered under the old name
171
+ if (!s || JSON.stringify([s.command, ...(s.args || [])]) !== JSON.stringify(adapterCmd())) return false
172
+ // (a Claude Code too old for the hook is registered without it)
173
+ return WAKE_EVENTS.every(ev => readSettings(settingsFile(dir)).hooks?.[ev]?.some((/** @type {any} */ g) => g.hooks?.some((/** @type {any} */ h) => isWakeHook(h) && h.command === wakeCmd()))) || !wakeSupport().ok
174
+ } catch { return false }
175
+ }
176
+
177
+ /** @param {string} dir */
178
+ const envFor = dir => {
179
+ const env = { ...process.env }
180
+ if (dir) env.CLAUDE_CONFIG_DIR = dir
181
+ else delete env.CLAUDE_CONFIG_DIR
182
+ return env
183
+ }
184
+
185
+ /** Register the nattermatic MCP server (user scope) in one account. @param {string} dir */
186
+ export function register(dir) {
187
+ const claude = claudeBin()
188
+ if (!claude) throw new Error('claude not found on PATH')
189
+ const env = envFor(dir)
190
+ /** @type {any} */
191
+ let was = null
192
+ try { was = JSON.parse(fs.readFileSync(configFile(dir), 'utf8')).mcpServers?.nattermatic } catch {}
193
+ spawnSync(claude, ['mcp', 'remove', '-s', 'user', 'nattermatic'], { stdio: 'ignore', env })
194
+ spawnSync(claude, ['mcp', 'remove', '-s', 'user', 'agentlink'], { stdio: 'ignore', env }) // its name before the rename
195
+ const r = spawnSync(claude, ['mcp', 'add', '-s', 'user', 'nattermatic', '--', ...adapterCmd()], { encoding: 'utf8', env })
196
+ if (r.status !== 0) {
197
+ // what worked before is better than nothing
198
+ if (was?.command) spawnSync(claude, ['mcp', 'add', '-s', 'user', 'nattermatic', '--', was.command, ...(was.args || [])], { stdio: 'ignore', env })
199
+ throw new Error((r.stderr || r.stdout || `exit ${r.status}`).trim())
200
+ }
201
+ rememberDir(dir)
202
+ if (wakeSupport().ok) installHook(dir)
203
+ }
204
+
205
+ /**
206
+ * Remove nattermatic from a Claude account. Returns false if Claude Code wasn't found to remove its
207
+ * MCP server registration (only the hook was removed).
208
+ * @param {string} dir
209
+ */
210
+ export function unregister(dir) {
211
+ const claude = claudeBin()
212
+ if (claude) for (const name of ['nattermatic', 'agentlink']) spawnSync(claude, ['mcp', 'remove', '-s', 'user', name], { stdio: 'ignore', env: envFor(dir) })
213
+ removeHook(dir)
214
+ return !!claude
215
+ }
216
+
217
+ // The hook that wakes idle sessions. It ends when it wakes the session, so each of these arms it
218
+ // again: a session starting, each prompt, the end of a turn, a turn that failed (which isn't a Stop),
219
+ // and reading the inbox, which is what a woken session does first: a turn the user then interrupts
220
+ // ends with neither Stop nor StopFailure. It runs in the background for as long as the session is
221
+ // idle, and Claude Code enforces a timeout even so: 20 days, the most a timer can hold with room to spare.
222
+ const WAKE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'PostToolUse']
223
+ // the oldest Claude Code this has been tested with. Older ones may not know these hook events, and
224
+ // settings they can't read would affect far more than nattermatic, so they don't get the hook
225
+ export const WAKE_MIN_CLAUDE = '2.1.318'
226
+ const WAKE_TIMEOUT_S = 20 * 86400
227
+ // reading messages and seeing who's online run unattended; sending keeps the session's normal rules
228
+ const WAKE_TOOLS = ['mcp__nattermatic__inbox', 'mcp__nattermatic__peers']
229
+
230
+ const settingsFile = (/** @type {string} */ dir) => path.join(dir || path.join(HOME, '.claude'), 'settings.json')
231
+ const wakeCmd = () => [...selfCmd().map(a => a.startsWith('-') ? a : sq(a)), 'wake'].join(' ')
232
+ /** @param {any} h */
233
+ // (agentlink: the hook as it was before the rename, replaced by this one)
234
+ const isWakeHook = h => typeof h?.command === 'string' && /(^|[\s'"/])(natter|nattermatic|agentlink)(\.js)?['"]? wake$/.test(h.command)
235
+
236
+ /** @param {string} file @returns {Record<string, any>} */
237
+ function readSettings(file) {
238
+ let text
239
+ try { text = fs.readFileSync(file, 'utf8') } catch (e) {
240
+ if (/** @type {any} */ (e).code === 'ENOENT' && !isDangling(file)) return {}
241
+ throw new Error(`can't read ${file}, so nattermatic left it alone (${errMsg(e)})`)
242
+ }
243
+ // never overwrite settings we can't read: they're the user's
244
+ try { const v = JSON.parse(text); if (v && typeof v === 'object' && !Array.isArray(v)) return v } catch {}
245
+ throw new Error(`${file} isn't valid JSON, so nattermatic left it alone. Fix it, then run natter setup again`)
246
+ }
247
+ /** A symlink to nothing: writing through it would quietly replace the link. @param {string} file */
248
+ function isDangling(file) { try { return fs.lstatSync(file).isSymbolicLink() } catch { return false } }
249
+ /**
250
+ * Replace one of the user's files in one step, keeping its mode, and through a symlink (they're often
251
+ * links into dotfiles). @param {string} file @param {string | Buffer} data @param {number} mode if it's new
252
+ */
253
+ function replaceFile(file, data, mode) {
254
+ const real = fs.existsSync(file) ? fs.realpathSync(file) : file
255
+ writeAtomic(real, data, fs.existsSync(real) ? fs.statSync(real).mode & 0o777 : mode)
256
+ }
257
+ /** @param {string} file @param {Record<string, any>} s */
258
+ function writeSettings(file, s) {
259
+ fs.mkdirSync(path.dirname(file), { recursive: true })
260
+ replaceFile(file, JSON.stringify(s, null, 2) + '\n', 0o600) // they can hold secrets in `env`
261
+ }
262
+ /** `s` with no nattermatic hook left, keeping everything else. @param {Record<string, any>} s */
263
+ function withoutHook(s) {
264
+ if (!s.hooks || typeof s.hooks !== 'object') return s
265
+ let took = false
266
+ for (const ev of WAKE_EVENTS) {
267
+ if (!Array.isArray(s.hooks[ev])) continue
268
+ s.hooks[ev] = s.hooks[ev].flatMap((/** @type {any} */ g) => {
269
+ if (!Array.isArray(g?.hooks) || !g.hooks.some(isWakeHook)) return [g]
270
+ took = true
271
+ const hooks = g.hooks.filter((/** @type {any} */ h) => !isWakeHook(h))
272
+ return hooks.length ? [{ ...g, hooks }] : []
273
+ })
274
+ if (took && !s.hooks[ev].length) delete s.hooks[ev]
275
+ }
276
+ if (took && !Object.keys(s.hooks).length) delete s.hooks
277
+ return s
278
+ }
279
+
280
+ /** Add the wake hook to one account's settings. @param {string} dir */
281
+ export function installHook(dir) {
282
+ const file = settingsFile(dir)
283
+ const s = withoutHook(readSettings(file))
284
+ s.hooks ??= {}
285
+ for (const ev of WAKE_EVENTS) (s.hooks[ev] ??= []).push({ ...(ev === 'PostToolUse' ? { matcher: 'mcp__nattermatic__inbox' } : {}), hooks: [{ type: 'command', command: wakeCmd(), async: true, asyncRewake: true, timeout: WAKE_TIMEOUT_S }] })
286
+ s.permissions ??= {}
287
+ // rules the user wrote for agentlink's tools, such as allowing send, keep applying under the new name
288
+ for (const k of ['allow', 'ask', 'deny']) if (Array.isArray(s.permissions[k])) s.permissions[k] = [...new Set(s.permissions[k].map(renamedRule))]
289
+ if (s.permissions.allow === undefined || Array.isArray(s.permissions.allow)) s.permissions.allow = [...new Set([...(s.permissions.allow || []), ...WAKE_TOOLS])]
290
+ writeSettings(file, s)
291
+ }
292
+
293
+ /** @param {string} dir */
294
+ export function removeHook(dir) {
295
+ const file = settingsFile(dir)
296
+ if (!fs.existsSync(file)) return
297
+ const before = JSON.stringify(readSettings(file))
298
+ const s = withoutHook(readSettings(file))
299
+ if (Array.isArray(s.permissions?.allow)) {
300
+ s.permissions.allow = s.permissions.allow.filter((/** @type {string} */ t) => !WAKE_TOOLS.includes(t) && !WAKE_TOOLS.includes(renamedRule(t)))
301
+ if (!s.permissions.allow.length) delete s.permissions.allow
302
+ if (!Object.keys(s.permissions).length) delete s.permissions
303
+ }
304
+ if (JSON.stringify(s) !== before) writeSettings(file, s)
305
+ }
306
+
307
+ /** Can the Claude Code here run the wake hook? @returns {{ ok: boolean, version: string }} */
308
+ export function wakeSupport() {
309
+ const claude = claudeBin()
310
+ const r = claude ? spawnSync(claude, ['--version'], { encoding: 'utf8', timeout: 15_000 }) : null
311
+ const version = (r?.stdout || '').match(/^\d+\.\d+\.\d+/m)?.[0] || ''
312
+ return { ok: !!version && cmpVersion(version, WAKE_MIN_CLAUDE) >= 0, version }
313
+ }
314
+
315
+ // Shell files are the user's, in whatever encoding: latin1 keeps every byte as it was
316
+ const readRc = (/** @type {string} */ rc) => {
317
+ try { return fs.readFileSync(rc, 'latin1') } catch (e) {
318
+ if (/** @type {any} */ (e).code === 'ENOENT' && !isDangling(rc)) return ''
319
+ throw e // never write over what we couldn't read
320
+ }
321
+ }
322
+ const writeRc = (/** @type {string} */ rc, /** @type {string} */ text) => replaceFile(rc, Buffer.from(text, 'latin1'), 0o644)
323
+ const asRc = (/** @type {string} */ s) => Buffer.from(s, 'utf8').toString('latin1')
324
+ const fromRc = (/** @type {string} */ s) => Buffer.from(s, 'latin1').toString('utf8')
325
+ /** The PATH directory in a managed block, if it has one. @param {string} text */
326
+ function blockDir(text) {
327
+ const m = text.match(new RegExp(BLOCK))?.[0].match(/^export PATH="((?:[^"\\]|\\.)*)":"\$PATH"$/m)
328
+ return m ? fromRc(m[1]).replace(/\\(.)/g, '$1') : null
329
+ }
330
+ // `natter uninstall` takes the PATH line out and `natter setup` puts it back, with no installer to say what it was
331
+ const pathDirFile = () => path.join(homeDir(), 'bin-dir')
332
+
333
+ /**
334
+ * Rewrite the managed block in the shell rc: the PATH entry, if one is needed. It used to hold launch
335
+ * aliases too; a re-run clears those. With nothing to add, nothing is added, least of all a new file.
336
+ * @param {{ pathDir?: string }} [opts]
337
+ */
338
+ export function writeShellBlock({ pathDir } = {}) {
339
+ const rc = primaryRc()
340
+ const cur = readRc(rc)
341
+ // a PATH line agentlink wrote into its old home: that home is this one now
342
+ const prevDir = blockDir(renamedBlock(cur))?.replace(new RegExp(`^${escRe(oldHome())}(?=/|$)`), homeDir()) ?? null
343
+ let saved = null
344
+ try { saved = fs.readFileSync(pathDirFile(), 'utf8').trim() || null } catch {}
345
+ // a re-run from a normal shell doesn't know the installer's bin dir: keep the one we wrote before
346
+ const dir = pathDir || prevDir || saved
347
+ // compare with the PATH the user's shell had before the installer touched it. A dir we wrote stays:
348
+ // it may be on this shell's PATH only because of our line
349
+ const userPath = (process.env.NATTERMATIC_ORIG_PATH || process.env.PATH || '').split(':')
350
+ const line = dir && (dir === prevDir || dir === saved || !userPath.includes(dir)) ? `export PATH=${asRc(dq(dir))}:"$PATH"` : null
351
+ const rest = stripBlock(renamedBlock(cur))
352
+ const next = line ? `${rest.replace(/\n*$/, '\n')}\n${BEGIN}\n${line}\n${END}\n` : rest
353
+ if (next !== cur) writeRc(rc, next)
354
+ fs.rmSync(pathDirFile(), { force: true })
355
+ }
356
+
357
+ /** Take the managed block out of the shell files. Returns what couldn't be done. */
358
+ export function removeShellBlock() {
359
+ /** @type {string[]} */
360
+ const problems = []
361
+ for (const rc of rcFiles()) {
362
+ try {
363
+ const cur = renamedBlock(readRc(rc))
364
+ if (!cur.includes(BEGIN)) continue
365
+ const dir = blockDir(cur)
366
+ if (dir) try { ensureHomeDir(); writeAtomic(pathDirFile(), dir, 0o600) } catch {} // only a convenience
367
+ const rest = stripBlock(cur)
368
+ // nothing of the user's left: an empty ~/.bash_profile would go on hiding ~/.profile
369
+ if (!rest.trim() && /\.bash_(profile|login)$/.test(rc) && !fs.lstatSync(rc).isSymbolicLink()) fs.rmSync(rc)
370
+ else writeRc(rc, rest)
371
+ } catch (e) { problems.push(`could not update ${rc}: ${errMsg(e)}`) }
372
+ }
373
+ return problems
374
+ }
375
+
376
+ /** Is this word Claude Code's print option, alone or among other short ones (-cp)? @param {string} w */
377
+ export const isPrintFlag = w => /^(-[a-zA-Z]*p[a-zA-Z]*|--print)(=|$)/.test(w)
378
+
379
+ /**
380
+ * `natter claude [args]`: run claude for the current account with nattermatic enabled.
381
+ * @param {string[]} args
382
+ * @param {() => Promise<unknown>} ensureNode
383
+ */
384
+ export async function launchClaude(args, ensureNode) {
385
+ const claude = claudeBin()
386
+ if (!claude) { console.error('nattermatic: claude not found on PATH'); process.exit(127) }
387
+ const dir = process.env.CLAUDE_CONFIG_DIR ? path.resolve(expand(process.env.CLAUDE_CONFIG_DIR)) : ''
388
+ // subcommands, print mode (which ignores channels) and an uninstalled nattermatic run claude as is
389
+ const disabled = fs.existsSync(path.join(homeDir(), 'disabled'))
390
+ if (disabled) console.error('nattermatic: uninstalled on this machine (run `natter setup` to re-enable); starting claude without it')
391
+ const passthrough = disabled || (args[0] && SUBCOMMANDS.has(args[0]))
392
+ const print = args.some(isPrintFlag)
393
+ if (!passthrough) {
394
+ if (!isRegistered(dir)) {
395
+ try { register(dir); console.error(`nattermatic: enabled for this Claude account (${dir || 'default'})`) }
396
+ catch (e) { console.error(`nattermatic: could not register with this account: ${errMsg(e)}`) }
397
+ const broken = checkAdapter()
398
+ if (broken) console.error(`nattermatic: WARNING: Claude Code can't start nattermatic, so this session won't have it: ${broken}`)
399
+ }
400
+ await Promise.resolve().then(ensureNode).catch(e => console.error(`nattermatic: ${errMsg(e)}`)) // never blocks claude
401
+ }
402
+ // NATTERMATIC_CHANNEL tells the adapter that this session really has the channel enabled; without
403
+ // it (a plain `claude`), pushed messages would be ignored, so the adapter keeps them for `inbox`
404
+ const channel = !passthrough && !print
405
+ const env = { ...process.env, NATTERMATIC_CHANNEL: channel ? '1' : '' } // not inherited from an outer session
406
+ const child = spawn(claude, channel ? [...CHANNEL_FLAGS, ...args] : args, { stdio: 'inherit', env })
407
+ process.on('SIGINT', () => {}) // the child owns the terminal, and gets this from it
408
+ for (const sig of /** @type {const} */ (['SIGTERM', 'SIGHUP'])) process.on(sig, () => child.kill(sig))
409
+ child.on('error', e => { console.error(`nattermatic: could not start claude: ${errMsg(e)}`); process.exit(127) })
410
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 128 + (os.constants.signals[signal] || 0) : 1)))
411
+ }
package/src/cli.js ADDED
@@ -0,0 +1,223 @@
1
+ // The command line's help, suggestions for mistyped commands, and shell completion.
2
+ // bin/natter.js runs the commands; this describes them.
3
+
4
+ /**
5
+ * @typedef {{ name: string, args?: string, summary: string, help?: string,
6
+ * flags?: [string, string][], plumbing?: boolean, hidden?: boolean }} Command
7
+ */
8
+
9
+ /** @type {Command[]} */
10
+ export const COMMANDS = [
11
+ {
12
+ name: 'setup', args: '[options]', summary: 'set up this machine, or update it after an upgrade',
13
+ help: `Starts a new network if this machine has none, and keeps its network if it has one.
14
+ To add a machine to an existing network, don't run this: run \`natter invite\` on a machine
15
+ that's already a member, and run the line it prints on the new one.
16
+ Also enables nattermatic for every Claude Code account on this machine, and for Codex if it's installed.`,
17
+ flags: [
18
+ ['--new-network', 'leave this machine\'s network and start a new one'],
19
+ ['--host <name>', 'the name other machines use for this one (default: its .local name)'],
20
+ ['--no-service', 'don\'t install the background service'],
21
+ ['--no-claude', 'don\'t enable nattermatic in Claude Code'],
22
+ ['--no-codex', 'don\'t enable nattermatic in Codex'],
23
+ ],
24
+ },
25
+ {
26
+ name: 'invite', args: '[--list | --revoke]', summary: 'print a one-time line that adds another machine',
27
+ help: `Prints a line to run on the new machine (or paste into Claude Code there). It lets one
28
+ machine join and expires in 24 hours.`,
29
+ flags: [
30
+ ['--list', 'show unused invites'],
31
+ ['--revoke', 'cancel all unused invites'],
32
+ ],
33
+ },
34
+ {
35
+ name: 'to', args: '<name@host> <message...>', summary: 'send a message to an agent',
36
+ help: `Everything after the address is the message. A bare name means this machine.
37
+ Prints whether it was delivered, is waiting in an inbox, or is held until the machine is back.
38
+ Example: natter to reviewer@laptop.local "the build is green"`,
39
+ flags: [['--from <name>', 'the sender name to show (default: cli)']],
40
+ },
41
+ { name: 'who', summary: 'show which agents are online, here and on other machines' },
42
+ { name: 'status', summary: 'show this machine\'s node status as JSON' },
43
+ {
44
+ name: 'remove', args: '<host>', summary: 'remove a machine from the network',
45
+ help: `The machine can no longer send or receive. Run it on a machine that joined the network
46
+ before the one you're removing: a machine can only remove machines that joined after it.`,
47
+ },
48
+ {
49
+ name: 'compact', args: '[--yes]', summary: 'shrink the member list (founding machine only)',
50
+ help: `Shows which machines a fresh member list would keep. Run it again with --yes to go ahead.
51
+ Only needed if the list nears its size limit after many machines have come and gone.`,
52
+ flags: [
53
+ ['--yes', 'go ahead'],
54
+ ['--force', 'go ahead even if some machines couldn\'t be checked in time'],
55
+ ],
56
+ },
57
+ {
58
+ name: 'claude', args: '[claude args...]', summary: 'start Claude Code with messages pushed into the session',
59
+ help: `Optional: ordinary \`claude\` sessions already get messages, and are woken by them.
60
+ This starts Claude Code with nattermatic as a channel, so messages appear in the session as they
61
+ arrive. Arguments are passed to claude.`,
62
+ },
63
+ { name: 'uninstall', summary: 'remove the background service and the Claude Code and Codex setup' },
64
+ {
65
+ name: 'completion', args: '<zsh | bash>', summary: 'print a shell completion script',
66
+ help: `zsh: add this line to ~/.zshrc (after compinit, if you call it):
67
+ eval "$(natter completion zsh)"
68
+ bash: add this line to ~/.bashrc:
69
+ eval "$(natter completion bash)"`,
70
+ },
71
+ { name: 'version', summary: 'print the version' },
72
+ { name: 'help', args: '[command]', summary: 'show help for nattermatic or one command' },
73
+ {
74
+ name: 'node', summary: 'run the node (normally run by the background service)', plumbing: true,
75
+ flags: [['--on-demand', 'exit if a node is already running, instead of standing by']],
76
+ },
77
+ {
78
+ name: 'mcp', args: '[--poll | --channel] [--name <name>]', summary: 'run the MCP adapter (normally run by your agent)', plumbing: true,
79
+ help: `Any MCP agent can join with this as a stdio server. --poll: the agent reads with the inbox
80
+ tool rather than being woken (use this for agents other than Claude Code).`,
81
+ flags: [
82
+ ['--poll', 'read messages with the inbox tool'],
83
+ ['--channel', 'push messages as a Claude Code channel'],
84
+ ['--name <name>', 'this agent\'s name (default: the working folder\'s name)'],
85
+ ],
86
+ },
87
+ { name: 'wake', summary: 'run by the Claude Code hook', hidden: true },
88
+ { name: 'codex-hook', summary: 'run by the Codex hook', hidden: true },
89
+ { name: 'codex-wake', summary: 'started by the Codex hook', hidden: true },
90
+ { name: '__complete', summary: 'used by the completion scripts', hidden: true },
91
+ ]
92
+
93
+ const byName = new Map(COMMANDS.map(c => [c.name, c]))
94
+ export const findCommand = (/** @type {string} */ name) => byName.get(name)
95
+
96
+ // send and peers are agentlink's names for to and who: scripts written for it keep working
97
+ const ALIASES = { '--version': 'version', '-v': 'version', '--help': 'help', '-h': 'help', send: 'to', peers: 'who' }
98
+ /** The command a first argument names, following aliases. */
99
+ export const resolve = (/** @type {string | undefined} */ a) => a === undefined ? 'help' : /** @type {Record<string,string>} */ (ALIASES)[a] || a
100
+
101
+ const line = (/** @type {Command} */ c) => {
102
+ const left = ` natter ${c.name}${c.args ? ` ${c.args}` : ''}`
103
+ return left.length < 40 ? left.padEnd(40) + c.summary : `${left}\n${' '.repeat(40)}${c.summary}`
104
+ }
105
+
106
+ /** @param {string} version */
107
+ export function mainHelp(version) {
108
+ const shown = COMMANDS.filter(c => !c.hidden)
109
+ return `nattermatic ${version} — lets AI agents on different Claude accounts and machines natter to each other
110
+
111
+ Usage: natter <command> [options]
112
+
113
+ ${shown.filter(c => !c.plumbing).map(line).join('\n')}
114
+
115
+ Run by your agent or the service, not usually by hand:
116
+ ${shown.filter(c => c.plumbing).map(line).join('\n')}
117
+
118
+ natter help <command> (or natter <command> --help) shows a command's options.
119
+ https://github.com/tombaldwin/nattermatic`
120
+ }
121
+
122
+ /** @param {Command} c */
123
+ export function commandHelp(c) {
124
+ const flags = c.flags?.length
125
+ ? `\n\nOptions:\n${c.flags.map(([f, d]) => ` ${f.padEnd(18)}${d}`).join('\n')}` : ''
126
+ return `Usage: natter ${c.name}${c.args ? ` ${c.args}` : ''}
127
+
128
+ ${c.summary[0].toUpperCase()}${c.summary.slice(1)}.${c.help ? `\n\n${c.help}` : ''}${flags}`
129
+ }
130
+
131
+ /** Edit distance counting a swap of neighbouring letters as one edit, for suggesting a command. */
132
+ function distance(/** @type {string} */ a, /** @type {string} */ b) {
133
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => i || j))
134
+ for (let i = 1; i <= a.length; i++) {
135
+ for (let j = 1; j <= b.length; j++) {
136
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
137
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
138
+ }
139
+ }
140
+ return d[a.length][b.length]
141
+ }
142
+
143
+ /** The visible command closest to a mistyped one, if any is close enough. */
144
+ export function suggest(/** @type {string} */ typed) {
145
+ const t = typed.toLowerCase()
146
+ let best = '', bestD = Infinity
147
+ // (the old names too: someone who typed sned meant send, which still works)
148
+ for (const name of [...COMMANDS.filter(c => !c.hidden).map(c => c.name), 'send', 'peers']) {
149
+ const d = name.startsWith(t) && t.length >= 2 ? 0 : distance(t, name)
150
+ if (d < bestD) { best = name; bestD = d }
151
+ }
152
+ return bestD <= Math.max(1, Math.floor(best.length / 3)) ? best : undefined
153
+ }
154
+
155
+ const visible = () => COMMANDS.filter(c => !c.hidden).map(c => c.name)
156
+ /** Option names a command takes, for completion (--flag only, without the value placeholder). */
157
+ const flagNames = (/** @type {Command} */ c) => (c.flags || []).map(([f]) => f.split(' ')[0])
158
+
159
+ /** @param {string} shell */
160
+ export function completionScript(shell) {
161
+ const cases = COMMANDS.filter(c => !c.hidden && c.flags?.length && c.name !== 'to') // to completes addresses instead
162
+ if (shell === 'zsh') {
163
+ return `#compdef natter nattermatic agentlink
164
+ # natter completion for zsh. Load it with: eval "$(natter completion zsh)"
165
+ _natter() {
166
+ local -a cmds
167
+ cmds=(${COMMANDS.filter(c => !c.hidden).map(c => `'${c.name}:${c.summary.replace(/'/g, `'\\''`).replace(/:/g, '\\:')}'`).join(' ')})
168
+ if (( CURRENT == 2 )); then
169
+ _describe -t commands 'natter command' cmds
170
+ return
171
+ fi
172
+ case $words[2] in
173
+ to|send)
174
+ if (( CURRENT == 3 )); then
175
+ local -a addrs; addrs=(\${(f)"$(natter __complete addresses 2>/dev/null)"})
176
+ compadd -- $addrs
177
+ fi ;;
178
+ remove)
179
+ if (( CURRENT == 3 )); then
180
+ local -a hosts; hosts=(\${(f)"$(natter __complete hosts 2>/dev/null)"})
181
+ compadd -- $hosts
182
+ fi ;;
183
+ help|completion)
184
+ if (( CURRENT == 3 )); then
185
+ if [[ $words[2] == help ]]; then compadd -- ${visible().join(' ')}; else compadd -- zsh bash; fi
186
+ fi ;;
187
+ claude) ;;
188
+ ${cases.map(c => ` ${c.name}) compadd -- ${flagNames(c).join(' ')} --help ;;`).join('\n')}
189
+ *) compadd -- --help ;;
190
+ esac
191
+ }
192
+ if (( $+functions[compdef] )); then compdef _natter natter nattermatic agentlink; else autoload -Uz compinit && compinit && compdef _natter natter nattermatic agentlink; fi
193
+ `
194
+ }
195
+ if (shell === 'bash') {
196
+ return `# natter completion for bash. Load it with: eval "$(natter completion bash)"
197
+ _natter() {
198
+ local cur=\${COMP_WORDS[COMP_CWORD]} words=""
199
+ if [ "$COMP_CWORD" -eq 1 ]; then
200
+ words="${visible().join(' ')}"
201
+ elif [ "$COMP_CWORD" -eq 2 ]; then
202
+ case \${COMP_WORDS[1]} in
203
+ to|send) words=$(natter __complete addresses 2>/dev/null) ;;
204
+ remove) words=$(natter __complete hosts 2>/dev/null) ;;
205
+ help) words="${visible().join(' ')}" ;;
206
+ completion) words="zsh bash" ;;
207
+ claude) return ;;
208
+ ${cases.map(c => ` ${c.name}) words="${flagNames(c).join(' ')} --help" ;;`).join('\n')}
209
+ *) words="--help" ;;
210
+ esac
211
+ else
212
+ case \${COMP_WORDS[1]} in
213
+ ${cases.map(c => ` ${c.name}) words="${flagNames(c).join(' ')}" ;;`).join('\n')}
214
+ esac
215
+ fi
216
+ local IFS=$'\\n'
217
+ COMPREPLY=($(IFS=$' \\n' compgen -W "$words" -- "$cur"))
218
+ }
219
+ complete -o default -F _natter natter nattermatic agentlink
220
+ `
221
+ }
222
+ throw new Error('usage: natter completion <zsh | bash>')
223
+ }