@aperant/framework 0.9.0 → 0.11.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 +120 -0
- package/agents/apt-verifier.md +6 -3
- package/dist/cli/commands/catch-up.d.mts +9 -0
- package/dist/cli/commands/catch-up.d.mts.map +1 -0
- package/dist/cli/commands/catch-up.mjs +64 -0
- package/dist/cli/commands/catch-up.mjs.map +1 -0
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +17 -1
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/pr-review.d.mts +16 -4
- package/dist/cli/commands/pr-review.d.mts.map +1 -1
- package/dist/cli/commands/pr-review.mjs +30 -11
- package/dist/cli/commands/pr-review.mjs.map +1 -1
- package/dist/cli/commands/route.d.mts.map +1 -1
- package/dist/cli/commands/route.mjs +92 -2
- package/dist/cli/commands/route.mjs.map +1 -1
- package/dist/cli/commands/validate-evidence.d.mts +1 -1
- package/dist/cli/config/share-policy.d.mts.map +1 -1
- package/dist/cli/config/share-policy.mjs +1 -0
- package/dist/cli/config/share-policy.mjs.map +1 -1
- package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
- package/dist/cli/config/upgrade-gitignore.mjs +1 -0
- package/dist/cli/config/upgrade-gitignore.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/coordination/catch-up.d.mts +26 -0
- package/dist/cli/coordination/catch-up.d.mts.map +1 -0
- package/dist/cli/coordination/catch-up.mjs +239 -0
- package/dist/cli/coordination/catch-up.mjs.map +1 -0
- package/dist/cli/coordination/last-seen.d.mts +45 -0
- package/dist/cli/coordination/last-seen.d.mts.map +1 -0
- package/dist/cli/coordination/last-seen.mjs +128 -0
- package/dist/cli/coordination/last-seen.mjs.map +1 -0
- package/dist/cli/coordination/store.d.mts +15 -0
- package/dist/cli/coordination/store.d.mts.map +1 -1
- package/dist/cli/coordination/store.mjs +16 -0
- package/dist/cli/coordination/store.mjs.map +1 -1
- package/dist/cli/design/frontmatter-schema.d.mts +8 -8
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +2 -0
- package/dist/cli/dispatch.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/git/default-branch.d.mts +51 -0
- package/dist/cli/git/default-branch.d.mts.map +1 -0
- package/dist/cli/git/default-branch.mjs +234 -0
- package/dist/cli/git/default-branch.mjs.map +1 -0
- package/dist/cli/git/identity.d.mts +3 -5
- package/dist/cli/git/identity.d.mts.map +1 -1
- package/dist/cli/git/identity.mjs +10 -4
- package/dist/cli/git/identity.mjs.map +1 -1
- package/dist/cli/install/legacy-paths.d.mts.map +1 -1
- package/dist/cli/install/legacy-paths.mjs +2 -0
- package/dist/cli/install/legacy-paths.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 +1 -0
- package/dist/cli/install/runtime-detect.d.mts.map +1 -1
- package/dist/cli/install/runtime-detect.mjs +5 -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/personas/sidecar.d.mts +1 -1
- package/dist/cli/roadmap/lifecycle.d.mts +1 -1
- package/dist/cli/roadmap/rollup.d.mts +2 -2
- 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/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 +2 -1
- package/dist/plugin/agents/apt-verifier.md +6 -3
- package/dist/plugin/skills/apt/SKILL.md +29 -0
- package/dist/plugin/skills/apt-catch-up/SKILL.md +79 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +59 -4
- package/dist/plugin/skills/apt-setup/SKILL.md +76 -0
- 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 +138 -138
- package/skills/apt/SKILL.md +29 -0
- package/skills/apt-catch-up/SKILL.md +79 -0
- package/skills/apt-pr-review/SKILL.md +59 -4
- package/skills/apt-setup/SKILL.md +76 -0
- package/skills/apt-verify/SKILL.md +1 -1
- package/skills/apt-verify-proof/SKILL.md +25 -1
- package/src/cli/commands/catch-up.mjs +67 -0
- package/src/cli/commands/init.mjs +19 -0
- package/src/cli/commands/pr-review.mjs +32 -11
- package/src/cli/commands/route.mjs +92 -1
- package/src/cli/config/share-policy.mjs +1 -0
- package/src/cli/config/upgrade-gitignore.mjs +1 -0
- package/src/cli/consistency/parse-review.mjs +5 -3
- package/src/cli/consistency/rules/r5-verdict-consistency.mjs +14 -9
- package/src/cli/coordination/catch-up.mjs +231 -0
- package/src/cli/coordination/last-seen.mjs +131 -0
- package/src/cli/coordination/store.mjs +18 -0
- package/src/cli/dispatch.mjs +2 -0
- package/src/cli/gate/gates/review-clean.mjs +4 -2
- package/src/cli/git/default-branch.mjs +250 -0
- package/src/cli/git/identity.mjs +9 -3
- package/src/cli/install/legacy-paths.mjs +2 -0
- 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 +9 -0
- package/src/cli/install/toml-merge.mjs +139 -0
- 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/runtime-detect.mjs +11 -204
- package/templates/config.json +13 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git/default-branch.mjs — local default-branch resolution + merged-PR parsing
|
|
3
|
+
* for the Team-Change Digest (LD-03/04/05).
|
|
4
|
+
*
|
|
5
|
+
* Pure git helpers (no .aperant IO). Every git call uses the no-throw
|
|
6
|
+
* execFileSync pattern from git/remote.mjs (stdio ['ignore','pipe','ignore'])
|
|
7
|
+
* so a non-git dir / unresolvable ref returns null instead of throwing.
|
|
8
|
+
*
|
|
9
|
+
* The resolver reads the LOCAL default-branch ref only — it NEVER calls
|
|
10
|
+
* `git fetch`. The digest surfaces work the user has actually pulled, not
|
|
11
|
+
* unfetched server state (ADR Consequences ¶1).
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run a git command, returning trimmed stdout or null on any error.
|
|
20
|
+
* @param {string} targetDir
|
|
21
|
+
* @param {string[]} args
|
|
22
|
+
* @returns {string|null}
|
|
23
|
+
*/
|
|
24
|
+
function git(targetDir, args) {
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync('git', args, {
|
|
27
|
+
cwd: targetDir,
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
}).trim()
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the local default-branch ref, with its current sha. Resolution order:
|
|
38
|
+
* 1. `git symbolic-ref refs/remotes/origin/HEAD` (strip the
|
|
39
|
+
* 'refs/remotes/origin/' prefix);
|
|
40
|
+
* 2. `git config init.defaultBranch`;
|
|
41
|
+
* 3. existence check of `main` then `master` via `git rev-parse --verify`.
|
|
42
|
+
*
|
|
43
|
+
* NEVER fetches. Returns null outside a git repo or when nothing resolves.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} targetDir
|
|
46
|
+
* @returns {{ branch: string, sha: string }|null}
|
|
47
|
+
*/
|
|
48
|
+
export function resolveLocalDefaultBranch(targetDir) {
|
|
49
|
+
// Fast bail when not a git repo.
|
|
50
|
+
if (git(targetDir, ['rev-parse', '--git-dir']) === null) return null
|
|
51
|
+
|
|
52
|
+
let branch = null
|
|
53
|
+
|
|
54
|
+
// (1) origin/HEAD symbolic-ref — the authoritative "what main points at".
|
|
55
|
+
const originHead = git(targetDir, ['symbolic-ref', 'refs/remotes/origin/HEAD'])
|
|
56
|
+
if (originHead?.startsWith('refs/remotes/origin/')) {
|
|
57
|
+
branch = originHead.slice('refs/remotes/origin/'.length)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// (2) init.defaultBranch config.
|
|
61
|
+
if (!branch) {
|
|
62
|
+
const configured = git(targetDir, ['config', 'init.defaultBranch'])
|
|
63
|
+
if (configured) branch = configured
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// (3) existence check main → master.
|
|
67
|
+
if (!branch) {
|
|
68
|
+
for (const candidate of ['main', 'master']) {
|
|
69
|
+
if (git(targetDir, ['rev-parse', '--verify', '--quiet', candidate]) !== null) {
|
|
70
|
+
branch = candidate
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!branch) return null
|
|
77
|
+
|
|
78
|
+
const sha = git(targetDir, ['rev-parse', branch])
|
|
79
|
+
if (!sha) return null
|
|
80
|
+
|
|
81
|
+
return { branch, sha }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse a PR number + title from a merge-commit subject/body.
|
|
86
|
+
*
|
|
87
|
+
* GitHub merge-commit format:
|
|
88
|
+
* subject: "Merge pull request #N from owner/branch"
|
|
89
|
+
* body: "<PR title>" (first non-empty body line)
|
|
90
|
+
* Squash-merge format:
|
|
91
|
+
* subject: "<PR title> (#N)"
|
|
92
|
+
*
|
|
93
|
+
* @param {string} subject
|
|
94
|
+
* @param {string} body
|
|
95
|
+
* @returns {{ number: number, title: string }|null}
|
|
96
|
+
*/
|
|
97
|
+
function parsePullRequest(subject, body) {
|
|
98
|
+
const mergeMatch = subject.match(/^Merge pull request #(\d+) from /)
|
|
99
|
+
if (mergeMatch) {
|
|
100
|
+
const number = Number(mergeMatch[1])
|
|
101
|
+
const firstBodyLine = (body || '')
|
|
102
|
+
.split('\n')
|
|
103
|
+
.map((l) => l.trim())
|
|
104
|
+
.find((l) => l.length > 0)
|
|
105
|
+
return { number, title: firstBodyLine || subject }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// GitHub squash-merge convention: greedy `(.*)` captures the longest prefix
|
|
109
|
+
// so titles with parentheses (e.g. "feat(core): ...") are handled correctly.
|
|
110
|
+
const squashMatch = subject.match(/^(.*)\s\(#(\d+)\)\s*$/)
|
|
111
|
+
if (squashMatch) {
|
|
112
|
+
return { number: Number(squashMatch[2]), title: squashMatch[1].trim() }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve both the introduced-commit author emails (LD-05) and a display name
|
|
120
|
+
* from a single `git log` call per merge. Replaces the former separate
|
|
121
|
+
* `introducedAuthorEmails` + `pickAuthorName` pair (PRF-002).
|
|
122
|
+
*
|
|
123
|
+
* @param {string} targetDir
|
|
124
|
+
* @param {string[]} parents parent shas of the merge commit
|
|
125
|
+
* @param {string} authorEmail the merge commit's own author email
|
|
126
|
+
* @param {string} currentUserEmail the user's git email (for name picking)
|
|
127
|
+
* @param {string} fallbackName merge/commit author name when no better pick
|
|
128
|
+
* @returns {{ emails: string[], authorName: string }}
|
|
129
|
+
*/
|
|
130
|
+
function resolveIntroducedAuthors(targetDir, parents, authorEmail, currentUserEmail, fallbackName) {
|
|
131
|
+
if (parents.length >= 2) {
|
|
132
|
+
const raw = git(targetDir, ['log', `${parents[0]}..${parents[1]}`, '--format=%ae%x1f%an'])
|
|
133
|
+
if (raw) {
|
|
134
|
+
const emails = new Set()
|
|
135
|
+
let pickedName = null
|
|
136
|
+
for (const line of raw.split('\n')) {
|
|
137
|
+
const [email, name] = line.split('\x1f')
|
|
138
|
+
if (!email) continue
|
|
139
|
+
const normEmail = email.trim().toLowerCase()
|
|
140
|
+
if (normEmail.length > 0) emails.add(normEmail)
|
|
141
|
+
if (!pickedName && normEmail !== currentUserEmail) {
|
|
142
|
+
pickedName = (name || fallbackName).trim()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (emails.size > 0) {
|
|
146
|
+
return { emails: [...emails], authorName: pickedName || fallbackName.trim() }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const own = authorEmail.trim().toLowerCase()
|
|
151
|
+
return {
|
|
152
|
+
emails: own.length > 0 ? [own] : [],
|
|
153
|
+
authorName: fallbackName.trim(),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* List merged PRs on the local default branch since `sinceSha`, attributed to
|
|
159
|
+
* whoever actually wrote the work (LD-04/05).
|
|
160
|
+
*
|
|
161
|
+
* Walks `git log --first-parent <sinceSha>..<defaultBranch>`. Each commit that
|
|
162
|
+
* parses as a PR (merge-commit or squash form) becomes one entry, attributed
|
|
163
|
+
* via its introduced-commit author emails. An entry is excluded only when ALL
|
|
164
|
+
* its introduced emails equal `currentUserEmail` (the user's own work) — so a
|
|
165
|
+
* PR the user merged but a teammate authored still appears.
|
|
166
|
+
*
|
|
167
|
+
* The scan is bounded (R3): at most `maxEntries * SCAN_MULTIPLIER` commits are
|
|
168
|
+
* inspected, so a stale marker can't enumerate an unbounded history.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} targetDir
|
|
171
|
+
* @param {object} opts
|
|
172
|
+
* @param {string} opts.sinceSha baseline sha (exclusive lower bound)
|
|
173
|
+
* @param {string} [opts.defaultBranch] branch to walk to (default: resolved)
|
|
174
|
+
* @param {string} opts.currentUserEmail the user's git email (own-work filter)
|
|
175
|
+
* @param {number} [opts.maxEntries=7] cap on returned entries
|
|
176
|
+
* @returns {{ entries: Array<{ number: number, title: string, author: string }>, more_count: number }}
|
|
177
|
+
*/
|
|
178
|
+
export function listMergedPullRequests(targetDir, opts = {}) {
|
|
179
|
+
const empty = { entries: [], more_count: 0, more_capped: false }
|
|
180
|
+
const sinceSha = opts.sinceSha
|
|
181
|
+
const maxEntries = Number.isInteger(opts.maxEntries) && opts.maxEntries > 0 ? opts.maxEntries : 7
|
|
182
|
+
const currentUserEmail = (opts.currentUserEmail || '').trim().toLowerCase()
|
|
183
|
+
|
|
184
|
+
let defaultBranch = opts.defaultBranch
|
|
185
|
+
if (!defaultBranch) {
|
|
186
|
+
const resolved = resolveLocalDefaultBranch(targetDir)
|
|
187
|
+
if (!resolved) return empty
|
|
188
|
+
defaultBranch = resolved.branch
|
|
189
|
+
}
|
|
190
|
+
if (!sinceSha) return empty
|
|
191
|
+
if (!/^[0-9a-f]{7,40}$/i.test(sinceSha)) return empty
|
|
192
|
+
|
|
193
|
+
const SCAN_MULTIPLIER = 4
|
|
194
|
+
const scanCap = maxEntries * SCAN_MULTIPLIER
|
|
195
|
+
|
|
196
|
+
// One commit per record, fields separated by \x1f, records by \x1e.
|
|
197
|
+
// %H sha · %P parents · %an author name · %ae author email · %s subject · %b body
|
|
198
|
+
const FIELD = '\x1f'
|
|
199
|
+
const RECORD = '\x1e'
|
|
200
|
+
const format = `%H${FIELD}%P${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${RECORD}`
|
|
201
|
+
const raw = git(targetDir, [
|
|
202
|
+
'log',
|
|
203
|
+
'--first-parent',
|
|
204
|
+
`--max-count=${scanCap}`,
|
|
205
|
+
`--format=${format}`,
|
|
206
|
+
`${sinceSha}..${defaultBranch}`,
|
|
207
|
+
])
|
|
208
|
+
if (!raw) return empty
|
|
209
|
+
|
|
210
|
+
const records = raw
|
|
211
|
+
.split(RECORD)
|
|
212
|
+
.map((r) => r.replace(/^\n+/, ''))
|
|
213
|
+
.filter((r) => r.trim().length > 0)
|
|
214
|
+
|
|
215
|
+
const matched = []
|
|
216
|
+
for (const rec of records) {
|
|
217
|
+
const [sha, parentsRaw, authorName, authorEmail, subject, body] = rec.split(FIELD)
|
|
218
|
+
if (!sha) continue
|
|
219
|
+
const pr = parsePullRequest(subject || '', body || '')
|
|
220
|
+
if (!pr) continue
|
|
221
|
+
|
|
222
|
+
const parents = (parentsRaw || '').trim().split(/\s+/).filter(Boolean)
|
|
223
|
+
const { emails, authorName: resolvedAuthor } = resolveIntroducedAuthors(
|
|
224
|
+
targetDir,
|
|
225
|
+
parents,
|
|
226
|
+
authorEmail || '',
|
|
227
|
+
currentUserEmail,
|
|
228
|
+
authorName || 'unknown',
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// Exclude only when EVERY introduced commit is the current user's own.
|
|
232
|
+
const allOwn =
|
|
233
|
+
currentUserEmail.length > 0 &&
|
|
234
|
+
emails.length > 0 &&
|
|
235
|
+
emails.every((e) => e === currentUserEmail)
|
|
236
|
+
if (allOwn) continue
|
|
237
|
+
|
|
238
|
+
matched.push({ number: pr.number, title: pr.title, author: resolvedAuthor })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const more_capped = records.length >= scanCap
|
|
242
|
+
if (matched.length <= maxEntries) {
|
|
243
|
+
return { entries: matched, more_count: 0, more_capped }
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
entries: matched.slice(0, maxEntries),
|
|
247
|
+
more_count: matched.length - maxEntries,
|
|
248
|
+
more_capped,
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/cli/git/identity.mjs
CHANGED
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { execSync } from 'node:child_process'
|
|
6
6
|
|
|
7
|
+
const _identityCache = new Map()
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Auto-detect the current user's git identity. Returns null if git config is unset.
|
|
11
|
+
* Memoized per targetDir for the process lifetime (git identity is stable within a CLI invocation).
|
|
9
12
|
*/
|
|
10
13
|
export function getGitIdentity(targetDir) {
|
|
14
|
+
if (_identityCache.has(targetDir)) return _identityCache.get(targetDir)
|
|
15
|
+
let result = null
|
|
11
16
|
try {
|
|
12
17
|
const name = execSync('git config user.name 2>/dev/null', {
|
|
13
18
|
cwd: targetDir,
|
|
@@ -17,11 +22,12 @@ export function getGitIdentity(targetDir) {
|
|
|
17
22
|
cwd: targetDir,
|
|
18
23
|
encoding: 'utf-8',
|
|
19
24
|
}).trim()
|
|
20
|
-
if (
|
|
21
|
-
return { name, email }
|
|
25
|
+
if (name && email) result = { name, email }
|
|
22
26
|
} catch {
|
|
23
|
-
|
|
27
|
+
/* no git config — result stays null */
|
|
24
28
|
}
|
|
29
|
+
_identityCache.set(targetDir, result)
|
|
30
|
+
return result
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -88,6 +88,7 @@ export const LEGACY_INSTALL_PATHS = Object.freeze({
|
|
|
88
88
|
'agents/apt.md',
|
|
89
89
|
'agents/apt-author-skill.md',
|
|
90
90
|
'agents/apt-bootstrap.md',
|
|
91
|
+
'agents/apt-catch-up.md',
|
|
91
92
|
'agents/apt-caveman.md',
|
|
92
93
|
'agents/apt-classify.md',
|
|
93
94
|
'agents/apt-close-task.md',
|
|
@@ -131,6 +132,7 @@ export const LEGACY_INSTALL_PATHS = Object.freeze({
|
|
|
131
132
|
'commands/apt.md',
|
|
132
133
|
'commands/apt-author-skill.md',
|
|
133
134
|
'commands/apt-bootstrap.md',
|
|
135
|
+
'commands/apt-catch-up.md',
|
|
134
136
|
'commands/apt-caveman.md',
|
|
135
137
|
'commands/apt-classify.md',
|
|
136
138
|
'commands/apt-close-task.md',
|
|
@@ -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
|
+
}
|