@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.
- package/CHANGELOG.md +142 -0
- package/agents/apt-pr-review-fixer.md +9 -4
- package/agents/apt-verifier.md +6 -3
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +157 -23
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/toolchain-detect.d.mts.map +1 -1
- package/dist/cli/commands/toolchain-detect.mjs +44 -0
- package/dist/cli/commands/toolchain-detect.mjs.map +1 -1
- package/dist/cli/commands/triage.d.mts.map +1 -1
- package/dist/cli/commands/triage.mjs +346 -13
- package/dist/cli/commands/triage.mjs.map +1 -1
- package/dist/cli/commands/uninstall.d.mts.map +1 -1
- package/dist/cli/commands/uninstall.mjs +11 -8
- package/dist/cli/commands/uninstall.mjs.map +1 -1
- package/dist/cli/consistency/parse-review.mjs +5 -3
- package/dist/cli/consistency/parse-review.mjs.map +1 -1
- package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
- package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +10 -8
- package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
- package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
- package/dist/cli/gate/gates/review-clean.mjs +4 -2
- package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
- package/dist/cli/install/mcp-provision.d.mts +26 -0
- package/dist/cli/install/mcp-provision.d.mts.map +1 -0
- package/dist/cli/install/mcp-provision.mjs +281 -0
- package/dist/cli/install/mcp-provision.mjs.map +1 -0
- package/dist/cli/install/mcp-server-specs.d.mts +80 -0
- package/dist/cli/install/mcp-server-specs.d.mts.map +1 -0
- package/dist/cli/install/mcp-server-specs.mjs +102 -0
- package/dist/cli/install/mcp-server-specs.mjs.map +1 -0
- package/dist/cli/install/runtime-detect.d.mts +27 -0
- package/dist/cli/install/runtime-detect.d.mts.map +1 -1
- package/dist/cli/install/runtime-detect.mjs +32 -0
- package/dist/cli/install/runtime-detect.mjs.map +1 -1
- package/dist/cli/install/toml-merge.d.mts +40 -0
- package/dist/cli/install/toml-merge.d.mts.map +1 -0
- package/dist/cli/install/toml-merge.mjs +134 -0
- package/dist/cli/install/toml-merge.mjs.map +1 -0
- package/dist/cli/util/aperant-section.d.mts +12 -0
- package/dist/cli/util/aperant-section.d.mts.map +1 -1
- package/dist/cli/util/aperant-section.mjs +12 -0
- package/dist/cli/util/aperant-section.mjs.map +1 -1
- package/dist/cli/util/copy.d.mts +27 -0
- package/dist/cli/util/copy.d.mts.map +1 -1
- package/dist/cli/util/copy.mjs +49 -5
- package/dist/cli/util/copy.mjs.map +1 -1
- package/dist/cli/util/events-path.d.mts +8 -0
- package/dist/cli/util/events-path.d.mts.map +1 -0
- package/dist/cli/util/events-path.mjs +23 -0
- package/dist/cli/util/events-path.mjs.map +1 -0
- package/dist/cli/util/runtime-capabilities.d.mts +30 -0
- package/dist/cli/util/runtime-capabilities.d.mts.map +1 -0
- package/dist/cli/util/runtime-capabilities.mjs +214 -0
- package/dist/cli/util/runtime-capabilities.mjs.map +1 -0
- package/dist/cli/verify-proof/audit.d.mts +2 -7
- package/dist/cli/verify-proof/audit.d.mts.map +1 -1
- package/dist/cli/verify-proof/audit.mjs +11 -17
- package/dist/cli/verify-proof/audit.mjs.map +1 -1
- package/dist/cli/verify-proof/idl/index.d.mts +12 -1
- package/dist/cli/verify-proof/idl/index.d.mts.map +1 -1
- package/dist/cli/verify-proof/idl/index.mjs +50 -1
- package/dist/cli/verify-proof/idl/index.mjs.map +1 -1
- package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
- package/dist/cli/verify-proof/resolver.mjs +51 -1
- package/dist/cli/verify-proof/resolver.mjs.map +1 -1
- package/dist/cli/verify-proof/runtime-detect.d.mts +2 -29
- package/dist/cli/verify-proof/runtime-detect.d.mts.map +1 -1
- package/dist/cli/verify-proof/runtime-detect.mjs +8 -203
- package/dist/cli/verify-proof/runtime-detect.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/apt-pr-review-fixer.md +9 -4
- package/dist/plugin/agents/apt-verifier.md +6 -3
- package/dist/plugin/skills/apt-pr-review/SKILL.md +106 -15
- package/dist/plugin/skills/apt-setup/SKILL.md +7 -6
- package/dist/plugin/skills/apt-triage/SKILL.md +8 -6
- package/dist/plugin/skills/apt-update/SKILL.md +1 -1
- package/dist/plugin/skills/apt-verify/SKILL.md +1 -1
- package/dist/plugin/skills/apt-verify-proof/SKILL.md +25 -1
- package/drivers/browser/skill/agent-browser/SKILL.md +51 -0
- package/drivers/tauri/README.md +107 -0
- package/drivers/tauri/driver.mjs +88 -0
- package/drivers/tauri/manifest.json +37 -0
- package/drivers/tauri/scripts/tauri-mcp.sh +41 -0
- package/package.json +1 -1
- package/skills/apt-pr-review/SKILL.md +106 -15
- package/skills/apt-setup/SKILL.md +7 -6
- package/skills/apt-triage/SKILL.md +8 -6
- package/skills/apt-update/SKILL.md +1 -1
- package/skills/apt-verify/SKILL.md +1 -1
- package/skills/apt-verify-proof/SKILL.md +25 -1
- package/src/cli/commands/init.mjs +163 -19
- package/src/cli/commands/toolchain-detect.mjs +44 -0
- package/src/cli/commands/triage.mjs +352 -11
- package/src/cli/commands/uninstall.mjs +11 -8
- package/src/cli/consistency/parse-review.mjs +5 -3
- package/src/cli/consistency/rules/r5-verdict-consistency.mjs +14 -9
- package/src/cli/gate/gates/review-clean.mjs +4 -2
- package/src/cli/install/mcp-provision.mjs +293 -0
- package/src/cli/install/mcp-server-specs.mjs +101 -0
- package/src/cli/install/runtime-detect.mjs +36 -0
- package/src/cli/install/toml-merge.mjs +139 -0
- package/src/cli/util/aperant-section.mjs +14 -0
- package/src/cli/util/copy.mjs +53 -8
- package/src/cli/util/events-path.mjs +24 -0
- package/src/cli/util/runtime-capabilities.mjs +216 -0
- package/src/cli/verify-proof/audit.mjs +11 -17
- package/src/cli/verify-proof/idl/index.mjs +49 -11
- package/src/cli/verify-proof/resolver.mjs +49 -2
- package/src/cli/verify-proof/runtime-detect.mjs +11 -204
- 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
|
+
}
|