@agfpd/iapeer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bin/iapeer +25 -0
  2. package/package.json +37 -0
  3. package/src/cli/cli.test.ts +130 -0
  4. package/src/cli/index.ts +608 -0
  5. package/src/cli/listTui.test.ts +70 -0
  6. package/src/cli/listTui.ts +165 -0
  7. package/src/codec/codec.test.ts +271 -0
  8. package/src/codec/index.ts +217 -0
  9. package/src/core/constants.test.ts +21 -0
  10. package/src/core/constants.ts +180 -0
  11. package/src/core/errors.ts +20 -0
  12. package/src/core/index.ts +3 -0
  13. package/src/core/normalize.test.ts +98 -0
  14. package/src/core/normalize.ts +89 -0
  15. package/src/core/socket.ts +63 -0
  16. package/src/create/create.test.ts +143 -0
  17. package/src/create/index.ts +178 -0
  18. package/src/daemon/daemon-http.test.ts +114 -0
  19. package/src/daemon/daemon.test.ts +103 -0
  20. package/src/daemon/index.ts +439 -0
  21. package/src/daemon/main.test.ts +194 -0
  22. package/src/daemon/main.ts +230 -0
  23. package/src/enable/enable.test.ts +92 -0
  24. package/src/enable/index.ts +381 -0
  25. package/src/identity/identity.test.ts +262 -0
  26. package/src/identity/index.ts +603 -0
  27. package/src/index.ts +27 -0
  28. package/src/init/index.ts +408 -0
  29. package/src/init/init.test.ts +171 -0
  30. package/src/init/runtime-resolve.test.ts +49 -0
  31. package/src/install/index.ts +84 -0
  32. package/src/install/install.test.ts +31 -0
  33. package/src/launch/adapters/claude.ts +250 -0
  34. package/src/launch/adapters/codex.ts +329 -0
  35. package/src/launch/adapters/notifier.ts +90 -0
  36. package/src/launch/adapters/telegram.ts +130 -0
  37. package/src/launch/bootstrap.test.ts +56 -0
  38. package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
  39. package/src/launch/composeSystemPrompt.test.ts +98 -0
  40. package/src/launch/composeSystemPrompt.ts +261 -0
  41. package/src/launch/index.ts +253 -0
  42. package/src/launch/launch.test.ts +233 -0
  43. package/src/launch/launchd.test.ts +363 -0
  44. package/src/launch/launchd.ts +375 -0
  45. package/src/launch/launchdRun.ts +168 -0
  46. package/src/launch/sockdir.test.ts +70 -0
  47. package/src/launch/types.ts +300 -0
  48. package/src/lifecycle/index.ts +840 -0
  49. package/src/lifecycle/lifecycle.test.ts +496 -0
  50. package/src/onboard/index.ts +135 -0
  51. package/src/onboard/onboard.test.ts +39 -0
  52. package/src/provision/index.ts +170 -0
  53. package/src/provision/provision.test.ts +104 -0
  54. package/src/registry/index.ts +453 -0
  55. package/src/registry/registry.test.ts +400 -0
  56. package/src/runtime/deploy.ts +230 -0
  57. package/src/runtime/index.ts +191 -0
  58. package/src/runtime/runtime.test.ts +226 -0
  59. package/src/storage/index.ts +331 -0
  60. package/src/storage/peers-home.test.ts +34 -0
  61. package/src/storage/storage.test.ts +65 -0
  62. package/src/transport/index.ts +522 -0
  63. package/tsconfig.json +17 -0
@@ -0,0 +1,408 @@
1
+ // init — the per-peer onboarding verb (`iapeer init`, contract Примитивы §init +
2
+ // Установка §INIT). DETERMINISTIC, non-interactive. Builds on provisionPeer (identity
3
+ // + registry + infra plist) and adds the per-peer ECOSYSTEM wiring a peer needs to
4
+ // talk: the HTTP-MCP transport config + the local doctrine template.
5
+ //
6
+ // THE BIG SIMPLIFICATION (install-gate snapped LIVE 08.06): a peer gets send_to_peer
7
+ // purely from a project-scope `.mcp.json` pointing at the host-wide HTTP-MCP daemon —
8
+ // NO plugin install, NO /reload-plugins, NO GC version-snapshots (the whole legacy
9
+ // persistent-peer install-gate class is gone). Verified: a cold-start claude session
10
+ // (--dangerously-skip-permissions, as the launch primitive runs it) auto-enables the
11
+ // `.mcp.json` http server and send_to_peer is callable on its FIRST turn, no approve.
12
+ //
13
+ // claude side here. codex side (`[mcp_servers.<name>]` in ~/.codex/config.toml +
14
+ // default_tools_approval_mode="approve") lands with its own live codex check.
15
+
16
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
17
+ import { homedir } from 'os'
18
+ import { join } from 'path'
19
+ import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, IAPEER_DIR, isInfraRuntime, type Runtime } from '../core/constants.ts'
20
+ import {
21
+ ensureLocalIapScaffold,
22
+ pluginStateDir,
23
+ writeFileAtomic,
24
+ type StorageOptions,
25
+ } from '../storage/index.ts'
26
+ import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
27
+ import { launchctlBootstrap, launchdPlistPath, type BootstrapResult } from '../launch/launchd.ts'
28
+ import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
29
+ import type { Intelligence } from '../core/constants.ts'
30
+
31
+ /** The MCP-server name the peer's `.mcp.json` uses for the foundation daemon. */
32
+ export const IAPEER_MCP_SERVER_NAME = 'iapeer'
33
+
34
+ /** Fallback HTTP-MCP daemon port when no router.json is published yet (the daemon
35
+ * binds this stable loopback port by default; init before daemon-start uses it). */
36
+ export const DEFAULT_DAEMON_MCP_PORT = 8765
37
+
38
+ const IAPEER_DOCTRINE_FILE = 'IAPEER.md'
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // Daemon URL resolution (router.json published by the daemon, else the default)
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Resolve the daemon HTTP-MCP URL to write into a peer's `.mcp.json`. Primary: the
46
+ * `tcp` field of the daemon's discovery file `~/.iapeer/state/iapeer/router.json`
47
+ * (published live by the daemon). Fallback: the well-known default loopback URL —
48
+ * so `iapeer init` works even before the daemon is started (the daemon binds the
49
+ * same stable port; IAPEER_PORT overrides it at the daemon, not here).
50
+ */
51
+ export function resolveDaemonMcpUrl(options: StorageOptions = {}): string {
52
+ const routerJson = join(pluginStateDir('iapeer', options), 'router.json')
53
+ try {
54
+ const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as { tcp?: unknown }
55
+ if (typeof parsed.tcp === 'string' && parsed.tcp) return parsed.tcp
56
+ } catch {
57
+ /* no router.json (daemon not started) → the well-known default below */
58
+ }
59
+ const env = options.env ?? process.env
60
+ const port = env.IAPEER_PORT?.trim() || String(DEFAULT_DAEMON_MCP_PORT)
61
+ return `http://127.0.0.1:${port}/mcp`
62
+ }
63
+
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+ // claude `.mcp.json` wiring (project-scope; auto-enables on cold-start)
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+
68
+ interface McpHttpServer {
69
+ type: 'http'
70
+ url: string
71
+ headers: Record<string, string>
72
+ }
73
+
74
+ /**
75
+ * Idempotently wire the foundation HTTP-MCP server into the peer's project-scope
76
+ * `<cwd>/.mcp.json` (claude-specific). MERGE, not clobber: existing mcpServers are
77
+ * preserved; only the `iapeer` entry is (re)written. The X-IAPeer-Identity header
78
+ * carries `claude-<personality>` so the daemon resolves the caller per request. The
79
+ * caller is authenticated by that header (the same proof live-verified at cold-start).
80
+ * Returns the written path. Re-running init is a no-op-equivalent (same bytes).
81
+ */
82
+ export function writeClaudeMcpConfig(cwd: string, personality: string, daemonUrl: string): string {
83
+ const path = join(cwd, '.mcp.json')
84
+ let doc: { mcpServers?: Record<string, unknown> } = {}
85
+ if (existsSync(path)) {
86
+ try {
87
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
88
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) doc = parsed
89
+ } catch {
90
+ // Malformed existing .mcp.json. Starting fresh would SILENTLY discard the
91
+ // operator's other mcpServers (audit #15/#22 — data loss). Back the original
92
+ // up VERBATIM first so re-init never loses foreign config; then proceed.
93
+ try {
94
+ copyFileSync(path, `${path}.corrupt.bak`)
95
+ } catch {
96
+ /* best-effort backup */
97
+ }
98
+ }
99
+ }
100
+ const server: McpHttpServer = {
101
+ type: 'http',
102
+ url: daemonUrl,
103
+ headers: { 'X-IAPeer-Identity': `claude-${personality}` },
104
+ }
105
+ doc.mcpServers = { ...(doc.mcpServers ?? {}), [IAPEER_MCP_SERVER_NAME]: server }
106
+ writeFileAtomic(path, `${JSON.stringify(doc, null, 2)}\n`, 0o644)
107
+ return path
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // codex MCP config (~/.codex/config.toml — HOST-WIDE; the token-free recipe)
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ //
114
+ // codex's CLI does NOT import tools from an OPEN streamable-HTTP MCP server — it marks
115
+ // it authStatus=unsupported (and BLOCKS on startup) unless an auth scheme is configured
116
+ // (codex bug #21532 / #4707). The token-free fix (PROVEN LIVE, codex 0.136, owner
117
+ // decision 08.06 — Артур rejected a real host-wide bearer as a localhost crutch): set a
118
+ // FIXED, NON-SECRET bearer (CODEX_DUMMY_BEARER). Setting `bearer_token_env_var` flips
119
+ // authStatus to `bearer_token` purely from the config FACT — codex does NOT require the
120
+ // server to validate the token. The daemon stays OPEN: it ignores `Authorization` and
121
+ // resolves the caller from the X-IAPeer-Identity header (the SAME loopback same-uid +
122
+ // per-peer-identity auth as the claude side). So NEITHER the daemon NOR the claude side
123
+ // changes; only codex's config + the launch env do. The launch sets CODEX_BEARER_ENV_VAR
124
+ // (=CODEX_DUMMY_BEARER) and PEER_IDENTITY; env_http_headers carries the latter per-peer.
125
+
126
+ export { CODEX_BEARER_ENV_VAR } from '../core/constants.ts'
127
+
128
+ /** `~/.codex/config.toml` (or $CODEX_HOME/config.toml). codex's config is HOST-WIDE,
129
+ * not per-peer/project-scope like claude's `.mcp.json`. */
130
+ export function codexConfigPath(options: StorageOptions = {}): string {
131
+ const env = options.env ?? process.env
132
+ const home = env.CODEX_HOME?.trim() || join(env.HOME?.trim() || homedir(), '.codex')
133
+ return join(home, 'config.toml')
134
+ }
135
+
136
+ /**
137
+ * Idempotently add the token-free `[mcp_servers.iapeer]` block to codex's host-wide
138
+ * config.toml (append-if-absent — never duplicates, never rewrites an existing block,
139
+ * never touches other servers/sections). The block (PROVEN LIVE — codex imports +
140
+ * calls send_to_peer with this exact shape):
141
+ * - url = the daemon HTTP-MCP endpoint.
142
+ * - default_tools_approval_mode = "approve" (no per-tool approval dialog; verified live).
143
+ * - bearer_token_env_var = "IAPEER_BEARER" — codex reads its bearer from this env var
144
+ * (the launch sets it to the NON-SECRET CODEX_DUMMY_BEARER). Its mere presence flips
145
+ * authStatus unsupported→bearer_token, so codex imports the tools; the OPEN daemon
146
+ * ignores the bearer (it authenticates by the identity header below).
147
+ * - env_http_headers."X-IAPeer-Identity" = "PEER_IDENTITY" — the PER-PEER caller
148
+ * identity, read from the PEER_IDENTITY env the launch sets, so ONE host-wide config
149
+ * serves every codex peer with its own identity (env_http_headers, verified live).
150
+ * Returns {path, added} (added=false when the block already existed).
151
+ */
152
+ export function writeCodexMcpConfig(daemonUrl: string, options: StorageOptions = {}): { path: string; added: boolean } {
153
+ const path = codexConfigPath(options)
154
+ let existing = ''
155
+ try {
156
+ existing = readFileSync(path, 'utf8')
157
+ } catch {
158
+ /* no config yet → create it */
159
+ }
160
+ if (/\[mcp_servers\.iapeer\]/.test(existing)) return { path, added: false } // idempotent
161
+ const block =
162
+ `\n[mcp_servers.${IAPEER_MCP_SERVER_NAME}]\n` +
163
+ `url = ${JSON.stringify(daemonUrl)}\n` +
164
+ `default_tools_approval_mode = "approve"\n` +
165
+ `bearer_token_env_var = "${CODEX_BEARER_ENV_VAR}"\n` +
166
+ `\n[mcp_servers.${IAPEER_MCP_SERVER_NAME}.env_http_headers]\n` +
167
+ `"X-IAPeer-Identity" = "PEER_IDENTITY"\n`
168
+ // Atomic write of the host-wide SHARED ~/.codex/config.toml (audit #12/#14): a torn
169
+ // writeFileSync could corrupt the operator's whole codex config. writeFileAtomic
170
+ // does tmp+fsync+rename and creates the dir.
171
+ writeFileAtomic(path, existing.replace(/\n*$/, '\n') + block, 0o644)
172
+ return { path, added: true }
173
+ }
174
+
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+ // Local doctrine template (<cwd>/.iapeer/IAPEER.md — personality/role; human fills)
177
+ // ─────────────────────────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Create the local doctrine template `<cwd>/.iapeer/IAPEER.md` if absent (contract:
181
+ * "шаблон .iapeer/IAPEER.md — пустой, человек заполняет"). It is the file that marks
182
+ * "this is a configured peer" (the launch primitive's bare-session gate keys on it)
183
+ * and where the peer's role/personality lives (merged into the system prompt, Канал
184
+ * A). NEVER overwrites an existing doctrine. Returns {path, created}.
185
+ */
186
+ export function ensureDoctrineTemplate(cwd: string): { path: string; created: boolean } {
187
+ const path = join(cwd, IAPEER_DIR, IAPEER_DOCTRINE_FILE)
188
+ if (existsSync(path)) return { path, created: false }
189
+ ensureLocalIapScaffold(cwd)
190
+ writeFileSync(
191
+ path,
192
+ [
193
+ '# Peer doctrine',
194
+ '',
195
+ '<!-- This is the local doctrine for this peer — its role, personality, and mandate.',
196
+ ' It is merged into the system prompt at launch (Канал A). Replace this with',
197
+ ' who this peer is and what it does. An empty doctrine launches a bare peer. -->',
198
+ '',
199
+ ].join('\n'),
200
+ { mode: 0o644 },
201
+ )
202
+ return { path, created: true }
203
+ }
204
+
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+ // initPeer — orchestrate: provision (identity + registry + infra plist) + per-peer
207
+ // ecosystem wiring (MCP transport per agentic runtime + doctrine template)
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Resolve the PRIMARY runtime for a peer cwd, runtime-aware and CONSISTENT with the
212
+ * contract (Примитивы §init): an explicit runtime wins; otherwise the runtime markers
213
+ * IN the cwd decide — `.claude` → claude, `.codex`-only → codex, both → claude primary
214
+ * (the agentic default order). No marker at all → claude (the default). This removes
215
+ * the old inconsistency where a `.codex`-only folder defaulted to claude as primary
216
+ * yet got a codex config — now the primary matches the markers, deterministically.
217
+ */
218
+ export function resolvePrimaryRuntime(cwd: string, explicit?: Runtime): Runtime {
219
+ if (explicit) return explicit
220
+ if (existsSync(join(cwd, '.claude'))) return 'claude'
221
+ if (existsSync(join(cwd, '.codex'))) return 'codex'
222
+ return 'claude'
223
+ }
224
+
225
+ export interface InitPeerOptions {
226
+ cwd: string
227
+ /** Primary runtime. Default: resolved from the cwd's runtime markers
228
+ * (resolvePrimaryRuntime) — `.claude` → claude, `.codex`-only → codex, else claude.
229
+ * Agentic peers init claude/codex; an infra peer (telegram/notifier) is provisioned
230
+ * but has no `.mcp.json` (it is a router, not an MCP client). */
231
+ runtime?: Runtime
232
+ personality?: string
233
+ description?: string
234
+ intelligence?: Intelligence
235
+ /** For an INFRA runtime: absolute path / PATH name of the runtime launcher, baked
236
+ * into the always-on plist (so launchd's minimal PATH resolves it). */
237
+ runtimeBin?: string
238
+ /** AUTO-bootstrap a freshly-installed INFRA plist (launchctl bootstrap) instead of
239
+ * write-and-wait (contract Фаза §5). Default true; only acts for an infra runtime
240
+ * whose plist is foundation-owned. Sandbox/foreign cases are guarded inside
241
+ * launchctlBootstrap. No-op for an agentic (warm-on-demand) runtime. */
242
+ bootstrap?: boolean
243
+ env?: NodeJS.ProcessEnv
244
+ warn?: (message: string) => void
245
+ }
246
+
247
+ export interface InitPeerResult extends ProvisionResult {
248
+ /** `.mcp.json` paths written (claude project-scope transport configs). */
249
+ mcpConfigPaths: string[]
250
+ /** codex config.toml path written with the token-free `[mcp_servers.iapeer]` block
251
+ * (dummy bearer + env_http_headers identity), or undefined when the peer is not
252
+ * codex. send_to_peer works immediately — see writeCodexMcpConfig. */
253
+ codexMcpConfigPath?: string
254
+ doctrinePath: string
255
+ doctrineCreated: boolean
256
+ daemonUrl: string
257
+ /** For an INFRA runtime: the per-peer self-config hook outcome (configured / failed /
258
+ * absent when no runtime package declares one). Undefined for an agentic peer. */
259
+ selfConfig?: SelfConfigResult
260
+ /** For an INFRA runtime with bootstrap enabled: the launchctl bootstrap outcome
261
+ * (loaded / already-loaded / skipped-sandbox / refused-foreign / failed). Undefined
262
+ * for an agentic peer, when bootstrap was disabled, or when self-config failed (a
263
+ * misconfigured always-on session is never loaded). */
264
+ bootstrapped?: BootstrapResult
265
+ }
266
+
267
+ /**
268
+ * Initialise a peer in one call: provision (identity + registry + infra plist) then
269
+ * the per-peer ecosystem wiring. For each AGENTIC runtime the peer declares, write
270
+ * the HTTP-MCP transport config (claude → project `.mcp.json`; codex → follow-up) so
271
+ * the peer has send_to_peer on cold-start. Always lay down the local doctrine
272
+ * template. Idempotent: re-running rewrites the same `.mcp.json` and never clobbers
273
+ * an existing doctrine.
274
+ */
275
+ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
276
+ const env = opts.env ?? process.env
277
+ // Runtime-aware + CONSISTENT: an explicit runtime wins; else the cwd's markers
278
+ // decide the primary (resolvePrimaryRuntime), so the primary always matches the
279
+ // config that gets written (no `.codex`-only-but-primary-claude mismatch).
280
+ const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime)
281
+ const provisioned = await provisionPeer({
282
+ cwd: opts.cwd,
283
+ runtime,
284
+ personality: opts.personality,
285
+ description: opts.description,
286
+ intelligence: opts.intelligence,
287
+ runtimeBin: opts.runtimeBin,
288
+ env,
289
+ warn: opts.warn,
290
+ })
291
+
292
+ const daemonUrl = resolveDaemonMcpUrl({ env })
293
+ const mcpConfigPaths: string[] = []
294
+ // Wire claude when the peer is a claude peer — primary runtime claude OR a `.claude`
295
+ // marker in the cwd (a multi-runtime peer). codex (config.toml) is a separate
296
+ // follow-up (its own live approval-mode check); an infra runtime is a router (no
297
+ // MCP client), so it gets no `.mcp.json`.
298
+ if (provisioned.runtime === 'claude' || hasClaudeMarker(provisioned.cwd)) {
299
+ mcpConfigPaths.push(writeClaudeMcpConfig(provisioned.cwd, provisioned.personality, daemonUrl))
300
+ }
301
+ // codex: write the token-free host-wide config.toml block (dummy bearer flips codex's
302
+ // auth gate; the OPEN daemon authenticates by the identity header). send_to_peer works
303
+ // immediately. An infra runtime is a router → no MCP client → nothing written.
304
+ let codexMcpConfigPath: string | undefined
305
+ if (provisioned.runtime === 'codex' || hasCodexMarker(provisioned.cwd)) {
306
+ codexMcpConfigPath = writeCodexMcpConfig(daemonUrl, { env }).path
307
+ }
308
+
309
+ const doctrine = ensureDoctrineTemplate(provisioned.cwd)
310
+
311
+ // INFRA peer: per-peer runtime self-config, THEN auto-bootstrap. For an agentic
312
+ // (warm-on-demand) runtime neither applies — it is daemon-woken, never launchd-held,
313
+ // and gets its MCP wiring above instead.
314
+ let selfConfig: SelfConfigResult | undefined
315
+ let bootstrapped: BootstrapResult | undefined
316
+ if (isInfraRuntime(provisioned.runtime)) {
317
+ // (1) PER-PEER self-config (the shared contract both provision modes call): ask the
318
+ // runtime package to "configure runtime state for this peer". A no-op when no
319
+ // runtime package declares a hook (selfConfig.state='absent').
320
+ selfConfig = runtimeSelfConfig(
321
+ {
322
+ personality: provisioned.personality,
323
+ cwd: provisioned.cwd,
324
+ runtime: provisioned.runtime,
325
+ intelligence: provisioned.intelligence,
326
+ },
327
+ { env },
328
+ )
329
+ if (selfConfig.state === 'failed') {
330
+ // FAIL-CLOSED: never load an always-on session whose runtime state is not
331
+ // configured (it would crash-loop). The plist is written (idempotent re-run
332
+ // after a fix will bootstrap); we just do not load it now.
333
+ opts.warn?.(`runtime self-config failed for ${provisioned.personality}: ${selfConfig.detail ?? ''} — NOT bootstrapping`)
334
+ } else if (opts.bootstrap !== false) {
335
+ // (2) AUTO-bootstrap (contract Фаза §5): load the plist NOW instead of
336
+ // write-and-wait. Fleet-safe / idempotent / sandbox-skipped inside.
337
+ bootstrapped = launchctlBootstrap(provisioned.personality, launchdPlistPath(provisioned.personality, env), env)
338
+ if (bootstrapped.state === 'failed' || bootstrapped.state === 'refused-foreign') {
339
+ opts.warn?.(`bootstrap ${bootstrapped.state}: ${bootstrapped.detail ?? ''}`)
340
+ }
341
+ }
342
+ }
343
+
344
+ return {
345
+ ...provisioned,
346
+ mcpConfigPaths,
347
+ codexMcpConfigPath,
348
+ doctrinePath: doctrine.path,
349
+ doctrineCreated: doctrine.created,
350
+ daemonUrl,
351
+ selfConfig,
352
+ bootstrapped,
353
+ }
354
+ }
355
+
356
+ /** A cwd is a claude/codex peer when it carries the runtime's marker dir (init scans
357
+ * the same markers as discoverPeerRuntimes). */
358
+ function hasClaudeMarker(cwd: string): boolean {
359
+ return existsSync(join(cwd, '.claude'))
360
+ }
361
+ function hasCodexMarker(cwd: string): boolean {
362
+ return existsSync(join(cwd, '.codex'))
363
+ }
364
+
365
+ // ─────────────────────────────────────────────────────────────────────────────
366
+ // CLI — `iapeer init` (run from the peer cwd) / `bun src/init/index.ts [cwd]`
367
+ // ─────────────────────────────────────────────────────────────────────────────
368
+
369
+ if (import.meta.main) {
370
+ const args = process.argv.slice(2)
371
+ const flags: Record<string, string> = {}
372
+ const positionals: string[] = []
373
+ for (let i = 0; i < args.length; i++) {
374
+ if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[++i] ?? ''
375
+ else positionals.push(args[i])
376
+ }
377
+ const cwd = positionals[0] ?? process.cwd()
378
+ initPeer({
379
+ cwd,
380
+ runtime: (flags.runtime as Runtime) || undefined,
381
+ personality: flags.personality,
382
+ description: flags.description,
383
+ intelligence: flags.intelligence as Intelligence | undefined,
384
+ runtimeBin: flags.bin || undefined,
385
+ bootstrap: 'no-bootstrap' in flags ? false : undefined,
386
+ warn: m => process.stderr.write(`warn: ${m}\n`),
387
+ })
388
+ .then(r => {
389
+ process.stdout.write(
390
+ `initialized peer "${r.personality}" (${r.runtime}, ${r.intelligence})\n` +
391
+ ` profile: ${r.profilePath}\n` +
392
+ ` registry: peers-profiles.json updated\n` +
393
+ (r.mcpConfigPaths.length
394
+ ? ` mcp: ${r.mcpConfigPaths.join(', ')} → ${r.daemonUrl}\n`
395
+ : r.codexMcpConfigPath
396
+ ? ` mcp: ${r.codexMcpConfigPath} (codex, token-free dummy-bearer — send_to_peer ready)\n`
397
+ : ' mcp: (none — infra/router runtime)\n') +
398
+ ` doctrine: ${r.doctrinePath}${r.doctrineCreated ? ' (template created — fill it in)' : ' (kept)'}\n` +
399
+ (r.plistPath ? ` plist: ${r.plistPath}\n` : '') +
400
+ `\nNext: fill in ${r.doctrinePath} and the peer's description, then launch with \`iapeer ${r.runtime}\`.\n`,
401
+ )
402
+ process.exit(0)
403
+ })
404
+ .catch(e => {
405
+ process.stderr.write(`init failed: ${e instanceof Error ? e.message : String(e)}\n`)
406
+ process.exit(1)
407
+ })
408
+ }
@@ -0,0 +1,171 @@
1
+ // init — per-peer onboarding: HTTP-MCP .mcp.json wiring (the install-gate-proven
2
+ // transport), doctrine template, and the initPeer orchestration over provisionPeer.
3
+
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import {
9
+ CODEX_BEARER_ENV_VAR,
10
+ IAPEER_MCP_SERVER_NAME,
11
+ codexConfigPath,
12
+ ensureDoctrineTemplate,
13
+ initPeer,
14
+ resolveDaemonMcpUrl,
15
+ writeClaudeMcpConfig,
16
+ writeCodexMcpConfig,
17
+ } from './index.ts'
18
+ import { readPeersIndex } from '../registry/index.ts'
19
+ import { readPeerProfile } from '../identity/index.ts'
20
+
21
+ let root: string
22
+ let cwd: string
23
+ function cleanEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
24
+ const env: NodeJS.ProcessEnv = { ...process.env, IAPEER_ROOT: root, ...extra }
25
+ delete env.PEER_PERSONALITY
26
+ delete env.PEER_IDENTITY
27
+ delete env.PEER_RUNTIME
28
+ return env
29
+ }
30
+
31
+ beforeEach(() => {
32
+ root = mkdtempSync(join(tmpdir(), 'iapeer-init-root-'))
33
+ cwd = mkdtempSync(join(tmpdir(), 'iapeer-init-cwd-'))
34
+ })
35
+ afterEach(() => {
36
+ rmSync(root, { recursive: true, force: true })
37
+ rmSync(cwd, { recursive: true, force: true })
38
+ })
39
+
40
+ describe('resolveDaemonMcpUrl', () => {
41
+ test('no router.json → well-known default loopback URL (IAPEER_PORT respected)', () => {
42
+ expect(resolveDaemonMcpUrl({ env: cleanEnv() })).toBe('http://127.0.0.1:8765/mcp')
43
+ expect(resolveDaemonMcpUrl({ env: cleanEnv({ IAPEER_PORT: '9999' }) })).toBe('http://127.0.0.1:9999/mcp')
44
+ })
45
+ test('router.json present → its tcp field (daemon-published endpoint)', () => {
46
+ const stateDir = join(root, 'state', 'iapeer')
47
+ mkdirSync(stateDir, { recursive: true })
48
+ writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
49
+ expect(resolveDaemonMcpUrl({ env: cleanEnv() })).toBe('http://127.0.0.1:8765/mcp')
50
+ })
51
+ })
52
+
53
+ describe('writeClaudeMcpConfig', () => {
54
+ test('writes the iapeer http server with the X-IAPeer-Identity header', () => {
55
+ const path = writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
56
+ const doc = JSON.parse(readFileSync(path, 'utf8'))
57
+ expect(doc.mcpServers[IAPEER_MCP_SERVER_NAME]).toEqual({
58
+ type: 'http',
59
+ url: 'http://127.0.0.1:8765/mcp',
60
+ headers: { 'X-IAPeer-Identity': 'claude-boris' },
61
+ })
62
+ })
63
+ test('MERGES — preserves other mcpServers, only (re)writes iapeer', () => {
64
+ writeFileSync(join(cwd, '.mcp.json'), JSON.stringify({ mcpServers: { other: { type: 'http', url: 'https://x/mcp' } } }))
65
+ writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
66
+ const doc = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf8'))
67
+ expect(doc.mcpServers.other).toEqual({ type: 'http', url: 'https://x/mcp' })
68
+ expect(doc.mcpServers.iapeer.headers['X-IAPeer-Identity']).toBe('claude-boris')
69
+ })
70
+ test('idempotent — re-running yields the same bytes', () => {
71
+ const p = writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
72
+ const first = readFileSync(p, 'utf8')
73
+ writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
74
+ expect(readFileSync(p, 'utf8')).toBe(first)
75
+ })
76
+ })
77
+
78
+ describe('ensureDoctrineTemplate', () => {
79
+ test('creates the template when absent', () => {
80
+ const { path, created } = ensureDoctrineTemplate(cwd)
81
+ expect(created).toBe(true)
82
+ expect(existsSync(path)).toBe(true)
83
+ expect(readFileSync(path, 'utf8')).toContain('Peer doctrine')
84
+ })
85
+ test('never overwrites an existing doctrine', () => {
86
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
87
+ writeFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'I am boris.')
88
+ const { created } = ensureDoctrineTemplate(cwd)
89
+ expect(created).toBe(false)
90
+ expect(readFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'utf8')).toBe('I am boris.')
91
+ })
92
+ })
93
+
94
+ describe('writeCodexMcpConfig (token-free recipe — dummy bearer + env_http_headers identity)', () => {
95
+ test('adds [mcp_servers.iapeer] with url + approve + bearer_token_env_var + env_http_headers', () => {
96
+ const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome-'))
97
+ try {
98
+ const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: codexHome }
99
+ const { path, added } = writeCodexMcpConfig('http://127.0.0.1:8765/mcp', { env })
100
+ expect(added).toBe(true)
101
+ expect(path).toBe(codexConfigPath({ env }))
102
+ const toml = readFileSync(path, 'utf8')
103
+ expect(toml).toContain('[mcp_servers.iapeer]')
104
+ expect(toml).toContain('url = "http://127.0.0.1:8765/mcp"')
105
+ expect(toml).toContain('default_tools_approval_mode = "approve"')
106
+ expect(toml).toContain(`bearer_token_env_var = "${CODEX_BEARER_ENV_VAR}"`)
107
+ expect(toml).toContain('[mcp_servers.iapeer.env_http_headers]')
108
+ expect(toml).toContain('"X-IAPeer-Identity" = "PEER_IDENTITY"')
109
+ } finally {
110
+ rmSync(codexHome, { recursive: true, force: true })
111
+ }
112
+ })
113
+ test('idempotent — re-run does NOT duplicate the block, and preserves other config', () => {
114
+ const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome2-'))
115
+ try {
116
+ const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: codexHome }
117
+ writeFileSync(codexConfigPath({ env }), '[other]\nkeep = true\n')
118
+ expect(writeCodexMcpConfig('http://x/mcp', { env }).added).toBe(true)
119
+ expect(writeCodexMcpConfig('http://x/mcp', { env }).added).toBe(false) // idempotent
120
+ const toml = readFileSync(codexConfigPath({ env }), 'utf8')
121
+ expect((toml.match(/\[mcp_servers\.iapeer\]/g) ?? []).length).toBe(1) // no duplicate
122
+ expect(toml).toContain('[other]') // other config preserved
123
+ expect(toml).toContain('keep = true')
124
+ } finally {
125
+ rmSync(codexHome, { recursive: true, force: true })
126
+ }
127
+ })
128
+ })
129
+
130
+ describe('initPeer orchestration', () => {
131
+ test('codex peer: profile + registry + token-free codex config + doctrine', async () => {
132
+ const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome3-'))
133
+ try {
134
+ const env = cleanEnv({ CODEX_HOME: codexHome })
135
+ const r = await initPeer({ cwd, runtime: 'codex', personality: 'cpeer', env })
136
+ expect(readPeerProfile(cwd)?.personality).toBe('cpeer')
137
+ expect(r.mcpConfigPaths.length).toBe(0) // no claude .mcp.json for a codex peer
138
+ expect(r.codexMcpConfigPath).toBe(codexConfigPath({ env }))
139
+ const toml = readFileSync(r.codexMcpConfigPath!, 'utf8')
140
+ expect(toml).toContain('[mcp_servers.iapeer]')
141
+ expect(toml).toContain(`bearer_token_env_var = "${CODEX_BEARER_ENV_VAR}"`) // token-free recipe wired
142
+ } finally {
143
+ rmSync(codexHome, { recursive: true, force: true })
144
+ }
145
+ })
146
+
147
+ test('claude peer: profile + registry + .mcp.json + doctrine, all wired', async () => {
148
+ const env = cleanEnv()
149
+ const r = await initPeer({ cwd, runtime: 'claude', personality: 'mypeer', env })
150
+ // identity + registry (via provision)
151
+ expect(readPeerProfile(cwd)?.personality).toBe('mypeer')
152
+ expect(readPeersIndex({ env }).peers.some(p => p.personality === 'mypeer')).toBe(true)
153
+ // .mcp.json wired to the daemon with the per-peer identity header
154
+ expect(r.mcpConfigPaths.length).toBe(1)
155
+ const mcp = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf8'))
156
+ expect(mcp.mcpServers.iapeer.headers['X-IAPeer-Identity']).toBe('claude-mypeer')
157
+ expect(mcp.mcpServers.iapeer.url).toBe('http://127.0.0.1:8765/mcp')
158
+ // doctrine template created
159
+ expect(r.doctrineCreated).toBe(true)
160
+ expect(existsSync(r.doctrinePath)).toBe(true)
161
+ })
162
+
163
+ test('idempotent — re-init does not throw and keeps an existing doctrine', async () => {
164
+ const env = cleanEnv()
165
+ await initPeer({ cwd, runtime: 'claude', personality: 'mypeer', env })
166
+ writeFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'custom doctrine')
167
+ const r2 = await initPeer({ cwd, runtime: 'claude', personality: 'mypeer', env })
168
+ expect(r2.doctrineCreated).toBe(false)
169
+ expect(readFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'utf8')).toBe('custom doctrine')
170
+ })
171
+ })
@@ -0,0 +1,49 @@
1
+ // resolvePrimaryRuntime — the runtime-aware, CONSISTENT primary-runtime resolution
2
+ // for init/create. Pure (filesystem markers only); no registry / launchd touched.
3
+
4
+ import { afterEach, describe, expect, test } from 'bun:test'
5
+ import { mkdirSync, mkdtempSync, rmSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import { resolvePrimaryRuntime } from './index.ts'
9
+
10
+ const dirs: string[] = []
11
+ function mkTmp(): string {
12
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-rt-'))
13
+ dirs.push(d)
14
+ return d
15
+ }
16
+ afterEach(() => {
17
+ while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
18
+ })
19
+
20
+ describe('resolvePrimaryRuntime', () => {
21
+ test('explicit runtime wins regardless of markers', () => {
22
+ const cwd = mkTmp()
23
+ mkdirSync(join(cwd, '.codex'))
24
+ expect(resolvePrimaryRuntime(cwd, 'telegram')).toBe('telegram')
25
+ })
26
+
27
+ test('no marker → claude (default)', () => {
28
+ expect(resolvePrimaryRuntime(mkTmp())).toBe('claude')
29
+ })
30
+
31
+ test('.codex-only folder → codex primary (was the inconsistency: defaulted to claude)', () => {
32
+ const cwd = mkTmp()
33
+ mkdirSync(join(cwd, '.codex'))
34
+ expect(resolvePrimaryRuntime(cwd)).toBe('codex')
35
+ })
36
+
37
+ test('.claude folder → claude', () => {
38
+ const cwd = mkTmp()
39
+ mkdirSync(join(cwd, '.claude'))
40
+ expect(resolvePrimaryRuntime(cwd)).toBe('claude')
41
+ })
42
+
43
+ test('both markers → claude primary (agentic default order)', () => {
44
+ const cwd = mkTmp()
45
+ mkdirSync(join(cwd, '.claude'))
46
+ mkdirSync(join(cwd, '.codex'))
47
+ expect(resolvePrimaryRuntime(cwd)).toBe('claude')
48
+ })
49
+ })