@aperant/framework 0.8.7 → 0.10.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 (111) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/agents/apt-pr-review-fixer.md +9 -4
  3. package/agents/apt-verifier.md +6 -3
  4. package/dist/cli/commands/init.d.mts.map +1 -1
  5. package/dist/cli/commands/init.mjs +157 -23
  6. package/dist/cli/commands/init.mjs.map +1 -1
  7. package/dist/cli/commands/toolchain-detect.d.mts.map +1 -1
  8. package/dist/cli/commands/toolchain-detect.mjs +44 -0
  9. package/dist/cli/commands/toolchain-detect.mjs.map +1 -1
  10. package/dist/cli/commands/triage.d.mts.map +1 -1
  11. package/dist/cli/commands/triage.mjs +346 -13
  12. package/dist/cli/commands/triage.mjs.map +1 -1
  13. package/dist/cli/commands/uninstall.d.mts.map +1 -1
  14. package/dist/cli/commands/uninstall.mjs +11 -8
  15. package/dist/cli/commands/uninstall.mjs.map +1 -1
  16. package/dist/cli/consistency/parse-review.mjs +5 -3
  17. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  18. package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
  19. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +10 -8
  20. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
  21. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  22. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  23. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  24. package/dist/cli/install/mcp-provision.d.mts +26 -0
  25. package/dist/cli/install/mcp-provision.d.mts.map +1 -0
  26. package/dist/cli/install/mcp-provision.mjs +281 -0
  27. package/dist/cli/install/mcp-provision.mjs.map +1 -0
  28. package/dist/cli/install/mcp-server-specs.d.mts +80 -0
  29. package/dist/cli/install/mcp-server-specs.d.mts.map +1 -0
  30. package/dist/cli/install/mcp-server-specs.mjs +102 -0
  31. package/dist/cli/install/mcp-server-specs.mjs.map +1 -0
  32. package/dist/cli/install/runtime-detect.d.mts +27 -0
  33. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  34. package/dist/cli/install/runtime-detect.mjs +32 -0
  35. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  36. package/dist/cli/install/toml-merge.d.mts +40 -0
  37. package/dist/cli/install/toml-merge.d.mts.map +1 -0
  38. package/dist/cli/install/toml-merge.mjs +134 -0
  39. package/dist/cli/install/toml-merge.mjs.map +1 -0
  40. package/dist/cli/util/aperant-section.d.mts +12 -0
  41. package/dist/cli/util/aperant-section.d.mts.map +1 -1
  42. package/dist/cli/util/aperant-section.mjs +12 -0
  43. package/dist/cli/util/aperant-section.mjs.map +1 -1
  44. package/dist/cli/util/copy.d.mts +27 -0
  45. package/dist/cli/util/copy.d.mts.map +1 -1
  46. package/dist/cli/util/copy.mjs +49 -5
  47. package/dist/cli/util/copy.mjs.map +1 -1
  48. package/dist/cli/util/events-path.d.mts +8 -0
  49. package/dist/cli/util/events-path.d.mts.map +1 -0
  50. package/dist/cli/util/events-path.mjs +23 -0
  51. package/dist/cli/util/events-path.mjs.map +1 -0
  52. package/dist/cli/util/runtime-capabilities.d.mts +30 -0
  53. package/dist/cli/util/runtime-capabilities.d.mts.map +1 -0
  54. package/dist/cli/util/runtime-capabilities.mjs +214 -0
  55. package/dist/cli/util/runtime-capabilities.mjs.map +1 -0
  56. package/dist/cli/verify-proof/audit.d.mts +2 -7
  57. package/dist/cli/verify-proof/audit.d.mts.map +1 -1
  58. package/dist/cli/verify-proof/audit.mjs +11 -17
  59. package/dist/cli/verify-proof/audit.mjs.map +1 -1
  60. package/dist/cli/verify-proof/idl/index.d.mts +12 -1
  61. package/dist/cli/verify-proof/idl/index.d.mts.map +1 -1
  62. package/dist/cli/verify-proof/idl/index.mjs +50 -1
  63. package/dist/cli/verify-proof/idl/index.mjs.map +1 -1
  64. package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
  65. package/dist/cli/verify-proof/resolver.mjs +51 -1
  66. package/dist/cli/verify-proof/resolver.mjs.map +1 -1
  67. package/dist/cli/verify-proof/runtime-detect.d.mts +2 -29
  68. package/dist/cli/verify-proof/runtime-detect.d.mts.map +1 -1
  69. package/dist/cli/verify-proof/runtime-detect.mjs +8 -203
  70. package/dist/cli/verify-proof/runtime-detect.mjs.map +1 -1
  71. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  72. package/dist/plugin/agents/apt-pr-review-fixer.md +9 -4
  73. package/dist/plugin/agents/apt-verifier.md +6 -3
  74. package/dist/plugin/skills/apt-pr-review/SKILL.md +106 -15
  75. package/dist/plugin/skills/apt-setup/SKILL.md +7 -6
  76. package/dist/plugin/skills/apt-triage/SKILL.md +8 -6
  77. package/dist/plugin/skills/apt-update/SKILL.md +1 -1
  78. package/dist/plugin/skills/apt-verify/SKILL.md +1 -1
  79. package/dist/plugin/skills/apt-verify-proof/SKILL.md +25 -1
  80. package/drivers/browser/skill/agent-browser/SKILL.md +51 -0
  81. package/drivers/tauri/README.md +107 -0
  82. package/drivers/tauri/driver.mjs +88 -0
  83. package/drivers/tauri/manifest.json +37 -0
  84. package/drivers/tauri/scripts/tauri-mcp.sh +41 -0
  85. package/package.json +1 -1
  86. package/skills/apt-pr-review/SKILL.md +106 -15
  87. package/skills/apt-setup/SKILL.md +7 -6
  88. package/skills/apt-triage/SKILL.md +8 -6
  89. package/skills/apt-update/SKILL.md +1 -1
  90. package/skills/apt-verify/SKILL.md +1 -1
  91. package/skills/apt-verify-proof/SKILL.md +25 -1
  92. package/src/cli/commands/init.mjs +163 -19
  93. package/src/cli/commands/toolchain-detect.mjs +44 -0
  94. package/src/cli/commands/triage.mjs +352 -11
  95. package/src/cli/commands/uninstall.mjs +11 -8
  96. package/src/cli/consistency/parse-review.mjs +5 -3
  97. package/src/cli/consistency/rules/r5-verdict-consistency.mjs +14 -9
  98. package/src/cli/gate/gates/review-clean.mjs +4 -2
  99. package/src/cli/install/mcp-provision.mjs +293 -0
  100. package/src/cli/install/mcp-server-specs.mjs +101 -0
  101. package/src/cli/install/runtime-detect.mjs +36 -0
  102. package/src/cli/install/toml-merge.mjs +139 -0
  103. package/src/cli/util/aperant-section.mjs +14 -0
  104. package/src/cli/util/copy.mjs +53 -8
  105. package/src/cli/util/events-path.mjs +24 -0
  106. package/src/cli/util/runtime-capabilities.mjs +216 -0
  107. package/src/cli/verify-proof/audit.mjs +11 -17
  108. package/src/cli/verify-proof/idl/index.mjs +49 -11
  109. package/src/cli/verify-proof/resolver.mjs +49 -2
  110. package/src/cli/verify-proof/runtime-detect.mjs +11 -204
  111. package/templates/config.json +2 -1
@@ -0,0 +1,293 @@
1
+ /**
2
+ * install/mcp-provision.mjs — TOFU install-time MCP-server + skill provisioner.
3
+ *
4
+ * Called from cmdInit (the only call site — the QUICK fast-path never invokes
5
+ * init, so the Fast Path Guarantee is structurally untouched). Detects the
6
+ * project's runtime, then for each selected host CLI that supports the action:
7
+ *
8
+ * - kind 'mcp-server' (electron/tauri) → write the server into the host's
9
+ * MCP config (claude → .mcp.json JSON merge; codex → .codex/config.toml
10
+ * TOML upsert).
11
+ * - kind 'skill' (agent-browser) → copy the vendored SKILL.md into the
12
+ * host's skill dir (claude → .claude/skills/; codex → .agents/skills/).
13
+ *
14
+ * Every write is TOFU/idempotent (ID-05): if the target server id / table /
15
+ * skill file already exists, SKIP (never diff/merge/overwrite) — this is the
16
+ * must-not-clobber invariant for a hand-authored electron
17
+ * SCREENSHOT_ENCRYPTION_KEY (US-3) and a user-edited agent-browser skill
18
+ * (US-6). Each action appends one audit line to .aperant/events/{date}.jsonl.
19
+ *
20
+ * Gated behind config.verification.provision_mcp (default true) so users can
21
+ * opt out. Never throws — wraps the body in try/catch → {status:'error'}.
22
+ */
23
+
24
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
25
+ import { basename, dirname, join, resolve } from 'node:path'
26
+ import { appendJsonl, atomicWriteJson, atomicWriteText } from '../util/atomic-write.mjs'
27
+ import { dailyEventsPath } from '../util/events-path.mjs'
28
+ import { capabilityToProvisionIds, PROVISION_SPECS } from './mcp-server-specs.mjs'
29
+ import { loadRuntimeCapabilities } from './runtime-detect.mjs'
30
+ import { upsertMcpServerToml } from './toml-merge.mjs'
31
+
32
+ /**
33
+ * Per-host writer registry (ID-07, ID-08). v1 = claude + codex. The MCP
34
+ * config path AND skill dir are EXPLICIT per host — neither is derived from
35
+ * the runtime's installRoot (Codex's MCP path .codex/config.toml diverges
36
+ * from its skill dir .agents/skills/). Adding gemini/opencode later = one
37
+ * entry each.
38
+ *
39
+ * @type {Record<string, {mcpFormat: 'json'|'toml', mcpConfigPath: string, skillDir: string}>}
40
+ */
41
+ const HOST_PROVISION_WRITERS = {
42
+ claude: { mcpFormat: 'json', mcpConfigPath: '.mcp.json', skillDir: '.claude/skills' },
43
+ codex: { mcpFormat: 'toml', mcpConfigPath: '.codex/config.toml', skillDir: '.agents/skills' },
44
+ }
45
+
46
+ /**
47
+ * Substitute the per-project socket basename into the tauri launcher
48
+ * template, deriving uniqueness from the project dir basename.
49
+ *
50
+ * @param {string} template
51
+ * @param {string} targetDir
52
+ * @returns {string}
53
+ */
54
+ function renderTauriLauncher(template, targetDir) {
55
+ const slug =
56
+ basename(resolve(targetDir))
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]+/g, '-')
59
+ .replace(/^-+|-+$/g, '') || 'tauri'
60
+ return template.replaceAll('{{PROJECT_SOCKET}}', slug)
61
+ }
62
+
63
+ /**
64
+ * Write the electron/tauri MCP server into a Claude `.mcp.json`. JSON merge:
65
+ * read-or-{}, ensure mcpServers, SKIP if the server id already exists, else
66
+ * insert {type:'stdio', command, args, env?} preserving all sibling keys.
67
+ *
68
+ * TOFU non-clobber (ID-05): if the existing file fails to parse OR parses to
69
+ * a non-plain-object root (array / null / primitive), do NOT overwrite it —
70
+ * return `skipped_unsafe` with a reason so the caller can audit it and leave
71
+ * the file byte-identical. (`typeof [] === 'object'` is why arrays must be
72
+ * excluded explicitly: a `.mcp.json` containing `[]` would otherwise pass the
73
+ * object guard, drop the user's content on stringify, and report `written`.)
74
+ *
75
+ * @param {string} configPath — absolute path to .mcp.json
76
+ * @param {string} serverId
77
+ * @param {import('./mcp-server-specs.mjs').McpServerSpec} spec
78
+ * @returns {{action: 'written'|'skipped'|'skipped_unsafe', reason?: string}}
79
+ */
80
+ function writeClaudeMcpServer(configPath, serverId, spec) {
81
+ let config = {}
82
+ if (existsSync(configPath)) {
83
+ try {
84
+ config = JSON.parse(readFileSync(configPath, 'utf-8'))
85
+ } catch {
86
+ // Malformed JSON: overwriting would erase every hand-authored MCP
87
+ // server. Leave the file untouched and surface it.
88
+ return { action: 'skipped_unsafe', reason: 'malformed_json' }
89
+ }
90
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
91
+ // Non-plain-object root (array / null / primitive): same non-clobber
92
+ // rule — never overwrite content we cannot safely merge into.
93
+ return { action: 'skipped_unsafe', reason: 'non_object_root' }
94
+ }
95
+ }
96
+ if (config.mcpServers === null || typeof config.mcpServers !== 'object') {
97
+ config.mcpServers = {}
98
+ }
99
+ // TOFU: never clobber a hand-authored server id.
100
+ if (Object.hasOwn(config.mcpServers, serverId)) return { action: 'skipped' }
101
+
102
+ const entry = { type: 'stdio', command: spec.command, args: spec.args ?? [] }
103
+ if (spec.env && Object.keys(spec.env).length > 0) entry.env = spec.env
104
+ config.mcpServers[serverId] = entry
105
+ atomicWriteJson(configPath, config)
106
+ return { action: 'written' }
107
+ }
108
+
109
+ /**
110
+ * Write the electron/tauri MCP server into a Codex `.codex/config.toml`.
111
+ *
112
+ * @param {string} configPath — absolute path to .codex/config.toml
113
+ * @param {string} serverId
114
+ * @param {import('./mcp-server-specs.mjs').McpServerSpec} spec
115
+ * @returns {{action: 'written'|'skipped'}}
116
+ */
117
+ function writeCodexMcpServer(configPath, serverId, spec) {
118
+ const existing = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
119
+ const { content, action } = upsertMcpServerToml(existing, serverId, {
120
+ command: spec.command,
121
+ args: spec.args ?? [],
122
+ ...(spec.env && Object.keys(spec.env).length > 0 ? { env: spec.env } : {}),
123
+ })
124
+ if (action === 'written') atomicWriteText(configPath, content)
125
+ return { action }
126
+ }
127
+
128
+ /**
129
+ * Copy the vendored skill into a host skill dir. TOFU: never overwrite an
130
+ * existing agent-browser/SKILL.md (a user may have edited it).
131
+ *
132
+ * @param {string} skillDestPath — absolute <skillDir>/<skillId>/SKILL.md
133
+ * @param {string} vendoredSkillPath — absolute source path
134
+ * @returns {'written'|'skipped'}
135
+ */
136
+ function copySkill(skillDestPath, vendoredSkillPath) {
137
+ if (existsSync(skillDestPath)) return 'skipped'
138
+ mkdirSync(dirname(skillDestPath), { recursive: true })
139
+ // copyFileSync is fine here — atomicWriteText would re-read; the dest is
140
+ // guaranteed absent (TOFU check above) so there is no clobber risk.
141
+ copyFileSync(vendoredSkillPath, skillDestPath)
142
+ return 'written'
143
+ }
144
+
145
+ /**
146
+ * Provision MCP servers + skills for the selected host CLIs.
147
+ *
148
+ * @param {Object} opts
149
+ * @param {string} opts.targetDir — project root being installed into
150
+ * @param {string[]} opts.selectedRuntimes — the runtimes the user selected
151
+ * @param {object} opts.config — the effective merged project config
152
+ * @param {string} opts.frameworkRoot — absolute path to packages/framework (for vendored skill resolution)
153
+ * @returns {{status: 'ok'|'disabled'|'error', writes?: Array<{host:string, spec_id:string, kind:string, action:string}>, reason?: string}}
154
+ */
155
+ export function provisionMcpConfig(opts) {
156
+ const targetDir = resolve(opts.targetDir)
157
+ const config = opts.config ?? {}
158
+ const selected = Array.isArray(opts.selectedRuntimes) ? opts.selectedRuntimes : []
159
+ const frameworkRoot = opts.frameworkRoot
160
+
161
+ try {
162
+ // (1) Opt-out gate (default true).
163
+ if (config?.verification?.provision_mcp === false) {
164
+ appendEvent(targetDir, 'mcp-provision.disabled', {
165
+ reason: 'verification.provision_mcp=false',
166
+ })
167
+ return { status: 'disabled' }
168
+ }
169
+
170
+ // (2) Detect runtime → derive spec ids.
171
+ const runtimeCaps = loadRuntimeCapabilities(targetDir)
172
+ const specIds = capabilityToProvisionIds(runtimeCaps)
173
+
174
+ /** @type {Array<{host:string, spec_id:string, kind:string, action:string}>} */
175
+ const writes = []
176
+
177
+ // Iterate every (selected host, spec) pair.
178
+ for (const host of selected) {
179
+ const writer = HOST_PROVISION_WRITERS[host]
180
+ if (!writer) {
181
+ // Selected host with no writer (gemini/opencode/... in v1) — record
182
+ // once as unsupported_host, no write.
183
+ writes.push({ host, spec_id: '*', kind: '*', action: 'unsupported_host' })
184
+ appendEvent(targetDir, 'mcp-provision.unsupported_host', { host })
185
+ continue
186
+ }
187
+
188
+ for (const specId of specIds) {
189
+ const spec = PROVISION_SPECS[specId]
190
+ if (!spec) continue
191
+
192
+ if (spec.kind === 'mcp-server') {
193
+ const configPath = join(targetDir, writer.mcpConfigPath)
194
+ const { action, reason } =
195
+ writer.mcpFormat === 'json'
196
+ ? writeClaudeMcpServer(configPath, specId, spec)
197
+ : writeCodexMcpServer(configPath, specId, spec)
198
+ writes.push({ host, spec_id: specId, kind: 'mcp-server', action })
199
+ appendEvent(targetDir, `mcp-provision.${action}`, {
200
+ host,
201
+ spec_id: specId,
202
+ config_path: writer.mcpConfigPath,
203
+ ...(reason ? { reason } : {}),
204
+ })
205
+ // (5) tauri needs its launcher script — only provision it once the
206
+ // MCP config entry is actually written, so a TOFU-skipped or
207
+ // skipped_unsafe config never leaves an orphan launcher + a
208
+ // misleading audit line.
209
+ if (spec.requiresLauncherScript && frameworkRoot && action === 'written') {
210
+ provisionTauriLauncher(targetDir, frameworkRoot, writes)
211
+ }
212
+ } else if (spec.kind === 'skill') {
213
+ if (!frameworkRoot) continue
214
+ const vendoredSkillPath = resolve(frameworkRoot, spec.vendoredSkillRelPath)
215
+ const skillDestPath = join(targetDir, writer.skillDir, spec.skillId, 'SKILL.md')
216
+ const action = copySkill(skillDestPath, vendoredSkillPath)
217
+ writes.push({ host, spec_id: specId, kind: 'skill', action })
218
+ appendEvent(
219
+ targetDir,
220
+ `skill-provision.${action}`,
221
+ {
222
+ host,
223
+ spec_id: specId,
224
+ skill_dir: writer.skillDir,
225
+ },
226
+ 'skill_provision',
227
+ )
228
+ }
229
+ }
230
+ }
231
+
232
+ return { status: 'ok', writes }
233
+ } catch (e) {
234
+ return { status: 'error', reason: e instanceof Error ? e.message : String(e) }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Copy the tauri launcher template into the project's scripts/ dir (TOFU).
240
+ * Records its own audit line; appends to `writes` with a synthetic host.
241
+ *
242
+ * @param {string} targetDir
243
+ * @param {string} frameworkRoot
244
+ * @param {Array<{host:string, spec_id:string, kind:string, action:string}>} writes
245
+ */
246
+ function provisionTauriLauncher(targetDir, frameworkRoot, writes) {
247
+ const destPath = join(targetDir, 'scripts', 'tauri-mcp.sh')
248
+ // Already recorded a launcher write/skip for this run? Only provision once.
249
+ if (writes.some((w) => w.kind === 'launcher')) return
250
+ if (existsSync(destPath)) {
251
+ writes.push({ host: '-', spec_id: 'tauri', kind: 'launcher', action: 'skipped' })
252
+ appendEvent(targetDir, 'mcp-provision.skipped', {
253
+ host: '-',
254
+ spec_id: 'tauri',
255
+ launcher: 'scripts/tauri-mcp.sh',
256
+ })
257
+ return
258
+ }
259
+ const templatePath = resolve(frameworkRoot, 'drivers/tauri/scripts/tauri-mcp.sh')
260
+ const template = readFileSync(templatePath, 'utf-8')
261
+ atomicWriteText(destPath, renderTauriLauncher(template, targetDir))
262
+ writes.push({ host: '-', spec_id: 'tauri', kind: 'launcher', action: 'written' })
263
+ appendEvent(targetDir, 'mcp-provision.written', {
264
+ host: '-',
265
+ spec_id: 'tauri',
266
+ launcher: 'scripts/tauri-mcp.sh',
267
+ })
268
+ }
269
+
270
+ /**
271
+ * Append one audit line to .aperant/events/{date}.jsonl, mirroring the
272
+ * daily-events shape. `kind` discriminates the provisioning channel:
273
+ * `mcp_provision` for MCP-server + launcher events, `skill_provision` for
274
+ * skill-copy events.
275
+ *
276
+ * @param {string} targetDir
277
+ * @param {string} op — e.g. mcp-provision.written, skill-provision.skipped
278
+ * @param {Record<string, unknown>} [extra]
279
+ * @param {string} [kind] — audit channel (default `mcp_provision`)
280
+ */
281
+ function appendEvent(targetDir, op, extra = {}, kind = 'mcp_provision') {
282
+ try {
283
+ const path = dailyEventsPath(targetDir)
284
+ appendJsonl(path, {
285
+ ts: new Date().toISOString(),
286
+ kind,
287
+ op,
288
+ ...extra,
289
+ })
290
+ } catch {
291
+ // Audit is best-effort — never fail provisioning on a log write error.
292
+ }
293
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * install/mcp-server-specs.mjs — single-sourced install-time provision specs.
3
+ *
4
+ * One registry, two kinds (ID-03):
5
+ * - kind 'mcp-server' (electron, tauri): the {command, args, env?} the
6
+ * installer writes into each host CLI's MCP config. Keyed by the SAME
7
+ * `transport.mcp_server_id` string the driver manifests carry.
8
+ * - kind 'skill' (agent-browser): the vendored SKILL.md the installer
9
+ * copies into each host CLI's skill dir. Keyed by the SAME
10
+ * `transport.skill_id` the browser driver manifest carries.
11
+ *
12
+ * We do NOT derive commands from the driver manifests — manifests are
13
+ * transport-agnostic by design (ID-01 keeps the verb→tool mapping in the
14
+ * driver; this is a separate install-only transport/skill concern). We do
15
+ * NOT add a per-driver mcp-server.json (over-engineering for 3 specs —
16
+ * CLAUDE.md Simplicity First).
17
+ *
18
+ * Pure module — no fs, no exec.
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} McpServerSpec
23
+ * @property {'mcp-server'} kind
24
+ * @property {string} command
25
+ * @property {string[]} args
26
+ * @property {Record<string, string>} [env]
27
+ * @property {boolean} [requiresLauncherScript] — tauri: copy scripts/tauri-mcp.sh first
28
+ *
29
+ * @typedef {Object} SkillSpec
30
+ * @property {'skill'} kind
31
+ * @property {string} skillId
32
+ * @property {string} vendoredSkillRelPath — relative to the framework root
33
+ *
34
+ * @typedef {McpServerSpec | SkillSpec} ProvisionSpec
35
+ */
36
+
37
+ /**
38
+ * The install-time provision specs, keyed by spec id.
39
+ *
40
+ * `electron` / `tauri` ids match each driver manifest's
41
+ * `transport.mcp_server_id`; `agent-browser` matches the browser driver
42
+ * manifest's `transport.skill_id`.
43
+ *
44
+ * @type {Record<string, ProvisionSpec>}
45
+ */
46
+ export const PROVISION_SPECS = {
47
+ // kind 'mcp-server'
48
+ electron: {
49
+ kind: 'mcp-server',
50
+ command: 'npx',
51
+ args: ['-y', 'electron-mcp-server'],
52
+ // No SCREENSHOT_ENCRYPTION_KEY — left for the user to add per ID-09.
53
+ env: {},
54
+ },
55
+ tauri: {
56
+ kind: 'mcp-server',
57
+ // The tauri-plugin-mcp-server npm bin ships without a shebang, so it
58
+ // cannot be invoked via `npx -y`. We launch it through a generated
59
+ // scripts/tauri-mcp.sh (ID-04) — the proven racing-ai shape.
60
+ command: 'bash',
61
+ args: ['./scripts/tauri-mcp.sh'],
62
+ requiresLauncherScript: true,
63
+ },
64
+ // kind 'skill'
65
+ 'agent-browser': {
66
+ kind: 'skill',
67
+ skillId: 'agent-browser',
68
+ // Vendored UNDER the browser driver (NOT skills/agent-browser/) so the
69
+ // install pipeline's discoverCanonicalFiles does not auto-install it
70
+ // into every project unconditionally — that would break the
71
+ // verification.provision_mcp opt-out (AC7) and the web-frontend runtime
72
+ // gate. The provisioner (mcp-provision.mjs) is the sole installer.
73
+ vendoredSkillRelPath: 'drivers/browser/skill/agent-browser/SKILL.md',
74
+ },
75
+ }
76
+
77
+ /**
78
+ * Map a project's runtime capabilities to the provision spec ids it
79
+ * triggers. A multi-surface monorepo (e.g. electron + nextjs) returns
80
+ * multiple ids.
81
+ *
82
+ * is_electron → ['electron']
83
+ * is_tauri → ['tauri']
84
+ * is_web_frontend|is_nextjs|is_nuxt → ['agent-browser']
85
+ *
86
+ * Accepts a partial capability record — only the flags it reads need be
87
+ * present (the rest default to absent/false).
88
+ *
89
+ * @param {Partial<import('../util/runtime-capabilities.mjs').RuntimeCapabilities>} runtimeCaps
90
+ * @returns {string[]}
91
+ */
92
+ export function capabilityToProvisionIds(runtimeCaps) {
93
+ const ids = []
94
+ if (!runtimeCaps || typeof runtimeCaps !== 'object') return ids
95
+ if (runtimeCaps.is_electron) ids.push('electron')
96
+ if (runtimeCaps.is_tauri) ids.push('tauri')
97
+ if (runtimeCaps.is_web_frontend || runtimeCaps.is_nextjs || runtimeCaps.is_nuxt) {
98
+ ids.push('agent-browser')
99
+ }
100
+ return ids
101
+ }
@@ -18,6 +18,15 @@ import { existsSync, statSync } from 'node:fs'
18
18
  import { join } from 'node:path'
19
19
  import { MANIFEST_FILENAME } from './manifest.mjs'
20
20
 
21
+ // Package.json-shape capability detection lives in util/ (shared with
22
+ // verify-proof/runtime-detect.mjs). Re-exported here so install-layer callers
23
+ // (e.g. install/mcp-provision.mjs) consume it from their OWN layer instead of
24
+ // reaching across into verify-proof/.
25
+ export {
26
+ detectFromPackageJson,
27
+ loadRuntimeCapabilities,
28
+ } from '../util/runtime-capabilities.mjs'
29
+
21
30
  /**
22
31
  * @typedef {Object} RuntimeDescriptor
23
32
  * @property {string} id Stable identifier (`claude`, `cursor`, …).
@@ -36,6 +45,18 @@ import { MANIFEST_FILENAME } from './manifest.mjs'
36
45
  * capitalized form of `id`. Use for runtimes
37
46
  * whose product branding differs from the
38
47
  * internal id (C58: `kilo` → "Kilo Code").
48
+ * @property {string|null} nativeInstructionFile
49
+ * The root-level instruction file this
50
+ * runtime's native CLI reads (e.g. `claude`
51
+ * reads `CLAUDE.md`, `codex` reads
52
+ * `AGENTS.md`). `init` stamps the
53
+ * `<!-- APT:framework-* -->` managed block
54
+ * into every distinct non-null target for the
55
+ * selected runtimes. `null` means the runtime
56
+ * installs to its own surface (e.g. cursor →
57
+ * `.cursor/rules`), NOT a root `.md` — `init`
58
+ * skips null entries. Nullable by design, not
59
+ * a future-work placeholder (ID-01).
39
60
  */
40
61
 
41
62
  /** @type {RuntimeDescriptor[]} */
@@ -46,6 +67,7 @@ export const RUNTIMES = Object.freeze([
46
67
  installRoot: '.claude',
47
68
  markers: ['.claude/settings.json', '.claude/settings.local.json'],
48
69
  cliId: 'claude-code',
70
+ nativeInstructionFile: 'CLAUDE.md',
49
71
  },
50
72
  {
51
73
  id: 'codex',
@@ -53,6 +75,7 @@ export const RUNTIMES = Object.freeze([
53
75
  installRoot: '.agents',
54
76
  markers: ['.codex/config.toml'],
55
77
  cliId: 'codex',
78
+ nativeInstructionFile: 'AGENTS.md',
56
79
  },
57
80
  {
58
81
  id: 'cursor',
@@ -60,6 +83,7 @@ export const RUNTIMES = Object.freeze([
60
83
  installRoot: '.cursor',
61
84
  markers: ['.cursor/settings.json'],
62
85
  cliId: 'cursor',
86
+ nativeInstructionFile: null,
63
87
  },
64
88
  {
65
89
  id: 'gemini',
@@ -67,6 +91,7 @@ export const RUNTIMES = Object.freeze([
67
91
  installRoot: '.gemini',
68
92
  markers: ['.gemini/settings.json'],
69
93
  cliId: 'gemini-cli',
94
+ nativeInstructionFile: 'GEMINI.md',
70
95
  },
71
96
  {
72
97
  id: 'opencode',
@@ -74,6 +99,7 @@ export const RUNTIMES = Object.freeze([
74
99
  installRoot: '.opencode',
75
100
  markers: ['.opencode/config.json'],
76
101
  cliId: 'opencode',
102
+ nativeInstructionFile: 'AGENTS.md',
77
103
  },
78
104
  {
79
105
  id: 'pi',
@@ -81,6 +107,7 @@ export const RUNTIMES = Object.freeze([
81
107
  installRoot: '.pi',
82
108
  markers: ['.pi/settings.json', '.pi/skills', '.pi/prompts'],
83
109
  cliId: 'pi',
110
+ nativeInstructionFile: null,
84
111
  },
85
112
  {
86
113
  id: 'kilo',
@@ -92,6 +119,7 @@ export const RUNTIMES = Object.freeze([
92
119
  // migration per spec ID-01). `--kilocode` is also accepted as a
93
120
  // deprecated alias of `--kilo` at the CLI flag parser layer.
94
121
  displayLabel: 'Kilo Code',
122
+ nativeInstructionFile: null,
95
123
  },
96
124
  {
97
125
  id: 'copilot',
@@ -99,6 +127,7 @@ export const RUNTIMES = Object.freeze([
99
127
  installRoot: '.github/copilot',
100
128
  markers: ['.github/copilot-instructions.md'],
101
129
  cliId: 'copilot',
130
+ nativeInstructionFile: null,
102
131
  },
103
132
  {
104
133
  id: 'antigravity',
@@ -106,6 +135,7 @@ export const RUNTIMES = Object.freeze([
106
135
  installRoot: '.antigravity',
107
136
  markers: ['.antigravity/config.json'],
108
137
  cliId: 'antigravity',
138
+ nativeInstructionFile: null,
109
139
  },
110
140
  {
111
141
  id: 'windsurf',
@@ -113,6 +143,7 @@ export const RUNTIMES = Object.freeze([
113
143
  installRoot: '.windsurf',
114
144
  markers: ['.windsurfrules'],
115
145
  cliId: 'windsurf',
146
+ nativeInstructionFile: null,
116
147
  },
117
148
  {
118
149
  id: 'augment',
@@ -120,6 +151,7 @@ export const RUNTIMES = Object.freeze([
120
151
  installRoot: '.augment',
121
152
  markers: ['.augment/config.json'],
122
153
  cliId: 'augment',
154
+ nativeInstructionFile: null,
123
155
  },
124
156
  {
125
157
  id: 'trae',
@@ -127,6 +159,7 @@ export const RUNTIMES = Object.freeze([
127
159
  installRoot: '.trae',
128
160
  markers: ['.trae/config.json'],
129
161
  cliId: 'trae',
162
+ nativeInstructionFile: null,
130
163
  },
131
164
  {
132
165
  id: 'qwen',
@@ -134,6 +167,7 @@ export const RUNTIMES = Object.freeze([
134
167
  installRoot: '.qwen',
135
168
  markers: ['.qwen/config.json'],
136
169
  cliId: 'qwen',
170
+ nativeInstructionFile: null,
137
171
  },
138
172
  {
139
173
  id: 'cline',
@@ -141,6 +175,7 @@ export const RUNTIMES = Object.freeze([
141
175
  installRoot: '.cline',
142
176
  markers: ['.cline/config.json'],
143
177
  cliId: 'cline',
178
+ nativeInstructionFile: null,
144
179
  },
145
180
  {
146
181
  id: 'codebuddy',
@@ -148,6 +183,7 @@ export const RUNTIMES = Object.freeze([
148
183
  installRoot: '.codebuddy',
149
184
  markers: ['.codebuddy/config.json'],
150
185
  cliId: 'codebuddy',
186
+ nativeInstructionFile: null,
151
187
  },
152
188
  ])
153
189
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * install/toml-merge.mjs — dependency-free minimal TOML upsert for the
3
+ * Codex MCP-server config table.
4
+ *
5
+ * Codex's MCP config lives at project-root `.codex/config.toml` as:
6
+ *
7
+ * [mcp_servers.<id>]
8
+ * command = "bash"
9
+ * args = ["./scripts/tauri-mcp.sh"]
10
+ *
11
+ * [mcp_servers.<id>.env]
12
+ * FOO = "bar"
13
+ *
14
+ * No TOML serializer ships in the framework and `@iarna/toml` is not a
15
+ * dependency, so we hand-roll the MINIMAL upsert covering ONLY this shape
16
+ * (string command, string[] args, optional nested env table of
17
+ * string=string). Presence is detected by scanning for the
18
+ * `[mcp_servers.<id>]` header line; if present, we SKIP unchanged (TOFU
19
+ * per ID-05) — never diff/merge/overwrite a hand-authored table. All other
20
+ * content is preserved verbatim; the new table block is appended at EOF.
21
+ *
22
+ * Pure module — string in, string out.
23
+ */
24
+
25
+ /**
26
+ * Escape a string for a TOML basic (double-quoted) string. Only the
27
+ * characters that can occur in a command/arg/env value: backslash and
28
+ * double-quote.
29
+ *
30
+ * @param {string} s
31
+ * @returns {string}
32
+ */
33
+ function tomlString(s) {
34
+ return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
35
+ }
36
+
37
+ /**
38
+ * Render a TOML array of double-quoted strings: `["a", "b"]`.
39
+ *
40
+ * @param {string[]} arr
41
+ * @returns {string}
42
+ */
43
+ function tomlStringArray(arr) {
44
+ return `[${arr.map((a) => tomlString(a)).join(', ')}]`
45
+ }
46
+
47
+ /**
48
+ * Render a TOML key. A bare key (TOML `[A-Za-z0-9_-]+`) is emitted verbatim;
49
+ * any other key is quoted as a TOML basic string so a non-bare env name
50
+ * (e.g. one containing `.`, a space, or `"`) cannot break the table or inject
51
+ * extra structure.
52
+ *
53
+ * @param {string} key
54
+ * @returns {string}
55
+ */
56
+ function tomlKey(key) {
57
+ return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key)
58
+ }
59
+
60
+ /**
61
+ * Whether `existingToml` already declares a `[mcp_servers.<id>]` table.
62
+ * Matches the header at the start of a line (ignoring leading whitespace),
63
+ * permitting an optional TOML trailing comment (` # …`) after the header —
64
+ * a hand-authored `[mcp_servers.tauri] # my server` must still count as
65
+ * present, otherwise the upsert would append a DUPLICATE table and produce
66
+ * an unparseable config (two same-named tables). The id is taken literally
67
+ * (dotted-key TOML ids do not occur for our server ids — electron/tauri are
68
+ * bare) and regex-escaped defensively. A `#`-prefixed comment LINE that
69
+ * merely mentions the header is NOT a match (the header must be the first
70
+ * non-whitespace token).
71
+ *
72
+ * @param {string} existingToml
73
+ * @param {string} serverId
74
+ * @returns {boolean}
75
+ */
76
+ export function hasMcpServerTable(existingToml, serverId) {
77
+ if (typeof existingToml !== 'string' || existingToml.length === 0) return false
78
+ const escapedId = serverId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
79
+ // `[mcp_servers.<id>]` at line start (after optional indent), optionally
80
+ // followed by whitespace and/or a `#` trailing comment, to EOL.
81
+ const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapedId}\\]\\s*(#.*)?$`)
82
+ const lines = existingToml.split(/\r?\n/)
83
+ for (const line of lines) {
84
+ if (headerRe.test(line)) return true
85
+ }
86
+ return false
87
+ }
88
+
89
+ /**
90
+ * Upsert a `[mcp_servers.<id>]` table into a Codex config.toml string.
91
+ *
92
+ * TOFU (ID-05): if a `[mcp_servers.<id>]` table already exists, returns the
93
+ * input unchanged with `action: 'skipped'`. Otherwise appends the new table
94
+ * (and a nested `[mcp_servers.<id>.env]` table when `env` is non-empty) at
95
+ * EOF, preserving all existing content verbatim, and returns the new
96
+ * content with `action: 'written'`.
97
+ *
98
+ * @param {string} existingToml — current file contents ('' when the file is absent)
99
+ * @param {string} serverId
100
+ * @param {{command: string, args: string[], env?: Record<string, string>}} spec
101
+ * @returns {{content: string, action: 'written'|'skipped'}}
102
+ */
103
+ export function upsertMcpServerToml(existingToml, serverId, spec) {
104
+ const existing = typeof existingToml === 'string' ? existingToml : ''
105
+ if (hasMcpServerTable(existing, serverId)) {
106
+ return { content: existing, action: 'skipped' }
107
+ }
108
+
109
+ const lines = []
110
+ lines.push(`[mcp_servers.${serverId}]`)
111
+ lines.push(`command = ${tomlString(spec.command)}`)
112
+ lines.push(`args = ${tomlStringArray(spec.args ?? [])}`)
113
+
114
+ const env = spec.env && typeof spec.env === 'object' ? spec.env : null
115
+ const envKeys = env ? Object.keys(env) : []
116
+ if (envKeys.length > 0) {
117
+ lines.push('')
118
+ lines.push(`[mcp_servers.${serverId}.env]`)
119
+ for (const key of envKeys) {
120
+ lines.push(`${tomlKey(key)} = ${tomlString(env[key])}`)
121
+ }
122
+ }
123
+
124
+ const block = lines.join('\n')
125
+
126
+ // Preserve existing content; separate the new block with exactly one blank
127
+ // line. An empty existing file yields just the block + trailing newline.
128
+ // Strip the existing file's trailing newline run first so a file that
129
+ // already ends in a blank line (`…\n\n`) does not produce a DOUBLE blank
130
+ // line before the appended block.
131
+ let content
132
+ if (existing.trim().length === 0) {
133
+ content = `${block}\n`
134
+ } else {
135
+ const head = existing.replace(/\n+$/, '')
136
+ content = `${head}\n\n${block}\n`
137
+ }
138
+ return { content, action: 'written' }
139
+ }