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/LICENSE +21 -0
- package/README.md +228 -4
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +229 -0
- package/package.json +62 -6
- package/src/cert.js +86 -0
- package/src/claude.js +406 -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 +100 -0
- package/src/local.js +193 -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 +451 -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/src/claude.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
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
|
+
/** @param {string} dir */
|
|
206
|
+
export function unregister(dir) {
|
|
207
|
+
const claude = claudeBin()
|
|
208
|
+
if (claude) for (const name of ['nattermatic', 'agentlink']) spawnSync(claude, ['mcp', 'remove', '-s', 'user', name], { stdio: 'ignore', env: envFor(dir) })
|
|
209
|
+
removeHook(dir)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The hook that wakes idle sessions. It ends when it wakes the session, so each of these arms it
|
|
213
|
+
// again: a session starting, each prompt, the end of a turn, a turn that failed (which isn't a Stop),
|
|
214
|
+
// and reading the inbox, which is what a woken session does first: a turn the user then interrupts
|
|
215
|
+
// ends with neither Stop nor StopFailure. It runs in the background for as long as the session is
|
|
216
|
+
// idle, and Claude Code enforces a timeout even so: 20 days, the most a timer can hold with room to spare.
|
|
217
|
+
const WAKE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'PostToolUse']
|
|
218
|
+
// the oldest Claude Code this has been tested with. Older ones may not know these hook events, and
|
|
219
|
+
// settings they can't read would affect far more than nattermatic, so they don't get the hook
|
|
220
|
+
export const WAKE_MIN_CLAUDE = '2.1.318'
|
|
221
|
+
const WAKE_TIMEOUT_S = 20 * 86400
|
|
222
|
+
// reading messages and seeing who's online run unattended; sending keeps the session's normal rules
|
|
223
|
+
const WAKE_TOOLS = ['mcp__nattermatic__inbox', 'mcp__nattermatic__peers']
|
|
224
|
+
|
|
225
|
+
const settingsFile = (/** @type {string} */ dir) => path.join(dir || path.join(HOME, '.claude'), 'settings.json')
|
|
226
|
+
const wakeCmd = () => [...selfCmd().map(a => a.startsWith('-') ? a : sq(a)), 'wake'].join(' ')
|
|
227
|
+
/** @param {any} h */
|
|
228
|
+
// (agentlink: the hook as it was before the rename, replaced by this one)
|
|
229
|
+
const isWakeHook = h => typeof h?.command === 'string' && /(^|[\s'"/])(natter|nattermatic|agentlink)(\.js)?['"]? wake$/.test(h.command)
|
|
230
|
+
|
|
231
|
+
/** @param {string} file @returns {Record<string, any>} */
|
|
232
|
+
function readSettings(file) {
|
|
233
|
+
let text
|
|
234
|
+
try { text = fs.readFileSync(file, 'utf8') } catch (e) {
|
|
235
|
+
if (/** @type {any} */ (e).code === 'ENOENT' && !isDangling(file)) return {}
|
|
236
|
+
throw new Error(`can't read ${file}, so nattermatic left it alone (${errMsg(e)})`)
|
|
237
|
+
}
|
|
238
|
+
// never overwrite settings we can't read: they're the user's
|
|
239
|
+
try { const v = JSON.parse(text); if (v && typeof v === 'object' && !Array.isArray(v)) return v } catch {}
|
|
240
|
+
throw new Error(`${file} isn't valid JSON, so nattermatic left it alone. Fix it, then run natter setup again`)
|
|
241
|
+
}
|
|
242
|
+
/** A symlink to nothing: writing through it would quietly replace the link. @param {string} file */
|
|
243
|
+
function isDangling(file) { try { return fs.lstatSync(file).isSymbolicLink() } catch { return false } }
|
|
244
|
+
/**
|
|
245
|
+
* Replace one of the user's files in one step, keeping its mode, and through a symlink (they're often
|
|
246
|
+
* links into dotfiles). @param {string} file @param {string | Buffer} data @param {number} mode if it's new
|
|
247
|
+
*/
|
|
248
|
+
function replaceFile(file, data, mode) {
|
|
249
|
+
const real = fs.existsSync(file) ? fs.realpathSync(file) : file
|
|
250
|
+
writeAtomic(real, data, fs.existsSync(real) ? fs.statSync(real).mode & 0o777 : mode)
|
|
251
|
+
}
|
|
252
|
+
/** @param {string} file @param {Record<string, any>} s */
|
|
253
|
+
function writeSettings(file, s) {
|
|
254
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
255
|
+
replaceFile(file, JSON.stringify(s, null, 2) + '\n', 0o600) // they can hold secrets in `env`
|
|
256
|
+
}
|
|
257
|
+
/** `s` with no nattermatic hook left, keeping everything else. @param {Record<string, any>} s */
|
|
258
|
+
function withoutHook(s) {
|
|
259
|
+
if (!s.hooks || typeof s.hooks !== 'object') return s
|
|
260
|
+
let took = false
|
|
261
|
+
for (const ev of WAKE_EVENTS) {
|
|
262
|
+
if (!Array.isArray(s.hooks[ev])) continue
|
|
263
|
+
s.hooks[ev] = s.hooks[ev].flatMap((/** @type {any} */ g) => {
|
|
264
|
+
if (!Array.isArray(g?.hooks) || !g.hooks.some(isWakeHook)) return [g]
|
|
265
|
+
took = true
|
|
266
|
+
const hooks = g.hooks.filter((/** @type {any} */ h) => !isWakeHook(h))
|
|
267
|
+
return hooks.length ? [{ ...g, hooks }] : []
|
|
268
|
+
})
|
|
269
|
+
if (took && !s.hooks[ev].length) delete s.hooks[ev]
|
|
270
|
+
}
|
|
271
|
+
if (took && !Object.keys(s.hooks).length) delete s.hooks
|
|
272
|
+
return s
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Add the wake hook to one account's settings. @param {string} dir */
|
|
276
|
+
export function installHook(dir) {
|
|
277
|
+
const file = settingsFile(dir)
|
|
278
|
+
const s = withoutHook(readSettings(file))
|
|
279
|
+
s.hooks ??= {}
|
|
280
|
+
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 }] })
|
|
281
|
+
s.permissions ??= {}
|
|
282
|
+
// rules the user wrote for agentlink's tools, such as allowing send, keep applying under the new name
|
|
283
|
+
for (const k of ['allow', 'ask', 'deny']) if (Array.isArray(s.permissions[k])) s.permissions[k] = [...new Set(s.permissions[k].map(renamedRule))]
|
|
284
|
+
if (s.permissions.allow === undefined || Array.isArray(s.permissions.allow)) s.permissions.allow = [...new Set([...(s.permissions.allow || []), ...WAKE_TOOLS])]
|
|
285
|
+
writeSettings(file, s)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @param {string} dir */
|
|
289
|
+
export function removeHook(dir) {
|
|
290
|
+
const file = settingsFile(dir)
|
|
291
|
+
if (!fs.existsSync(file)) return
|
|
292
|
+
const before = JSON.stringify(readSettings(file))
|
|
293
|
+
const s = withoutHook(readSettings(file))
|
|
294
|
+
if (Array.isArray(s.permissions?.allow)) {
|
|
295
|
+
s.permissions.allow = s.permissions.allow.filter((/** @type {string} */ t) => !WAKE_TOOLS.includes(t) && !WAKE_TOOLS.includes(renamedRule(t)))
|
|
296
|
+
if (!s.permissions.allow.length) delete s.permissions.allow
|
|
297
|
+
if (!Object.keys(s.permissions).length) delete s.permissions
|
|
298
|
+
}
|
|
299
|
+
if (JSON.stringify(s) !== before) writeSettings(file, s)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Can the Claude Code here run the wake hook? @returns {{ ok: boolean, version: string }} */
|
|
303
|
+
export function wakeSupport() {
|
|
304
|
+
const claude = claudeBin()
|
|
305
|
+
const r = claude ? spawnSync(claude, ['--version'], { encoding: 'utf8', timeout: 15_000 }) : null
|
|
306
|
+
const version = (r?.stdout || '').match(/^\d+\.\d+\.\d+/m)?.[0] || ''
|
|
307
|
+
return { ok: !!version && cmpVersion(version, WAKE_MIN_CLAUDE) >= 0, version }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Shell files are the user's, in whatever encoding: latin1 keeps every byte as it was
|
|
311
|
+
const readRc = (/** @type {string} */ rc) => {
|
|
312
|
+
try { return fs.readFileSync(rc, 'latin1') } catch (e) {
|
|
313
|
+
if (/** @type {any} */ (e).code === 'ENOENT' && !isDangling(rc)) return ''
|
|
314
|
+
throw e // never write over what we couldn't read
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const writeRc = (/** @type {string} */ rc, /** @type {string} */ text) => replaceFile(rc, Buffer.from(text, 'latin1'), 0o644)
|
|
318
|
+
const asRc = (/** @type {string} */ s) => Buffer.from(s, 'utf8').toString('latin1')
|
|
319
|
+
const fromRc = (/** @type {string} */ s) => Buffer.from(s, 'latin1').toString('utf8')
|
|
320
|
+
/** The PATH directory in a managed block, if it has one. @param {string} text */
|
|
321
|
+
function blockDir(text) {
|
|
322
|
+
const m = text.match(new RegExp(BLOCK))?.[0].match(/^export PATH="((?:[^"\\]|\\.)*)":"\$PATH"$/m)
|
|
323
|
+
return m ? fromRc(m[1]).replace(/\\(.)/g, '$1') : null
|
|
324
|
+
}
|
|
325
|
+
// `natter uninstall` takes the PATH line out and `natter setup` puts it back, with no installer to say what it was
|
|
326
|
+
const pathDirFile = () => path.join(homeDir(), 'bin-dir')
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Rewrite the managed block in the shell rc: the PATH entry, if one is needed. It used to hold launch
|
|
330
|
+
* aliases too; a re-run clears those. With nothing to add, nothing is added, least of all a new file.
|
|
331
|
+
* @param {{ pathDir?: string }} [opts]
|
|
332
|
+
*/
|
|
333
|
+
export function writeShellBlock({ pathDir } = {}) {
|
|
334
|
+
const rc = primaryRc()
|
|
335
|
+
const cur = readRc(rc)
|
|
336
|
+
// a PATH line agentlink wrote into its old home: that home is this one now
|
|
337
|
+
const prevDir = blockDir(renamedBlock(cur))?.replace(new RegExp(`^${escRe(oldHome())}(?=/|$)`), homeDir()) ?? null
|
|
338
|
+
let saved = null
|
|
339
|
+
try { saved = fs.readFileSync(pathDirFile(), 'utf8').trim() || null } catch {}
|
|
340
|
+
// a re-run from a normal shell doesn't know the installer's bin dir: keep the one we wrote before
|
|
341
|
+
const dir = pathDir || prevDir || saved
|
|
342
|
+
// compare with the PATH the user's shell had before the installer touched it. A dir we wrote stays:
|
|
343
|
+
// it may be on this shell's PATH only because of our line
|
|
344
|
+
const userPath = (process.env.NATTERMATIC_ORIG_PATH || process.env.PATH || '').split(':')
|
|
345
|
+
const line = dir && (dir === prevDir || dir === saved || !userPath.includes(dir)) ? `export PATH=${asRc(dq(dir))}:"$PATH"` : null
|
|
346
|
+
const rest = stripBlock(renamedBlock(cur))
|
|
347
|
+
const next = line ? `${rest.replace(/\n*$/, '\n')}\n${BEGIN}\n${line}\n${END}\n` : rest
|
|
348
|
+
if (next !== cur) writeRc(rc, next)
|
|
349
|
+
fs.rmSync(pathDirFile(), { force: true })
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Take the managed block out of the shell files. Returns what couldn't be done. */
|
|
353
|
+
export function removeShellBlock() {
|
|
354
|
+
/** @type {string[]} */
|
|
355
|
+
const problems = []
|
|
356
|
+
for (const rc of rcFiles()) {
|
|
357
|
+
try {
|
|
358
|
+
const cur = renamedBlock(readRc(rc))
|
|
359
|
+
if (!cur.includes(BEGIN)) continue
|
|
360
|
+
const dir = blockDir(cur)
|
|
361
|
+
if (dir) try { ensureHomeDir(); writeAtomic(pathDirFile(), dir, 0o600) } catch {} // only a convenience
|
|
362
|
+
const rest = stripBlock(cur)
|
|
363
|
+
// nothing of the user's left: an empty ~/.bash_profile would go on hiding ~/.profile
|
|
364
|
+
if (!rest.trim() && /\.bash_(profile|login)$/.test(rc) && !fs.lstatSync(rc).isSymbolicLink()) fs.rmSync(rc)
|
|
365
|
+
else writeRc(rc, rest)
|
|
366
|
+
} catch (e) { problems.push(`could not update ${rc}: ${errMsg(e)}`) }
|
|
367
|
+
}
|
|
368
|
+
return problems
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Is this word Claude Code's print option, alone or among other short ones (-cp)? @param {string} w */
|
|
372
|
+
export const isPrintFlag = w => /^(-[a-zA-Z]*p[a-zA-Z]*|--print)(=|$)/.test(w)
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* `natter claude [args]`: run claude for the current account with nattermatic enabled.
|
|
376
|
+
* @param {string[]} args
|
|
377
|
+
* @param {() => Promise<unknown>} ensureNode
|
|
378
|
+
*/
|
|
379
|
+
export async function launchClaude(args, ensureNode) {
|
|
380
|
+
const claude = claudeBin()
|
|
381
|
+
if (!claude) { console.error('nattermatic: claude not found on PATH'); process.exit(127) }
|
|
382
|
+
const dir = process.env.CLAUDE_CONFIG_DIR ? path.resolve(expand(process.env.CLAUDE_CONFIG_DIR)) : ''
|
|
383
|
+
// subcommands, print mode (which ignores channels) and an uninstalled nattermatic run claude as is
|
|
384
|
+
const disabled = fs.existsSync(path.join(homeDir(), 'disabled'))
|
|
385
|
+
if (disabled) console.error('nattermatic: uninstalled on this machine (run `natter setup` to re-enable); starting claude without it')
|
|
386
|
+
const passthrough = disabled || (args[0] && SUBCOMMANDS.has(args[0]))
|
|
387
|
+
const print = args.some(isPrintFlag)
|
|
388
|
+
if (!passthrough) {
|
|
389
|
+
if (!isRegistered(dir)) {
|
|
390
|
+
try { register(dir); console.error(`nattermatic: enabled for this Claude account (${dir || 'default'})`) }
|
|
391
|
+
catch (e) { console.error(`nattermatic: could not register with this account: ${errMsg(e)}`) }
|
|
392
|
+
const broken = checkAdapter()
|
|
393
|
+
if (broken) console.error(`nattermatic: WARNING: Claude Code can't start nattermatic, so this session won't have it: ${broken}`)
|
|
394
|
+
}
|
|
395
|
+
await Promise.resolve().then(ensureNode).catch(e => console.error(`nattermatic: ${errMsg(e)}`)) // never blocks claude
|
|
396
|
+
}
|
|
397
|
+
// NATTERMATIC_CHANNEL tells the adapter that this session really has the channel enabled; without
|
|
398
|
+
// it (a plain `claude`), pushed messages would be ignored, so the adapter keeps them for `inbox`
|
|
399
|
+
const channel = !passthrough && !print
|
|
400
|
+
const env = { ...process.env, NATTERMATIC_CHANNEL: channel ? '1' : '' } // not inherited from an outer session
|
|
401
|
+
const child = spawn(claude, channel ? [...CHANNEL_FLAGS, ...args] : args, { stdio: 'inherit', env })
|
|
402
|
+
process.on('SIGINT', () => {}) // the child owns the terminal, and gets this from it
|
|
403
|
+
for (const sig of /** @type {const} */ (['SIGTERM', 'SIGHUP'])) process.on(sig, () => child.kill(sig))
|
|
404
|
+
child.on('error', e => { console.error(`nattermatic: could not start claude: ${errMsg(e)}`); process.exit(127) })
|
|
405
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 128 + (os.constants.signals[signal] || 0) : 1)))
|
|
406
|
+
}
|
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
|
+
}
|