@plimeor/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -0
- package/package.json +39 -0
- package/src/adapters/claude.ts +112 -0
- package/src/adapters/codex.ts +118 -0
- package/src/adapters/extensions.ts +1235 -0
- package/src/adapters/index.ts +9 -0
- package/src/adapters/kiro.ts +57 -0
- package/src/adapters/pi.ts +65 -0
- package/src/adapters/shared.ts +338 -0
- package/src/errors.ts +43 -0
- package/src/index.ts +34 -0
- package/src/output.ts +183 -0
- package/src/process.ts +183 -0
- package/src/registry.ts +34 -0
- package/src/schema.ts +18 -0
- package/src/types.ts +166 -0
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { lstat, mkdir, open, readFile, readlink, rename, rm, rmdir, stat, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionFacet,
|
|
7
|
+
ExtensionIssue,
|
|
8
|
+
ExtensionResourceKind,
|
|
9
|
+
ExtensionResult,
|
|
10
|
+
HarnessContext,
|
|
11
|
+
HarnessExtension,
|
|
12
|
+
HarnessId,
|
|
13
|
+
HookResource,
|
|
14
|
+
McpServerResource
|
|
15
|
+
} from '../types'
|
|
16
|
+
|
|
17
|
+
type SupportedResource = 'skills' | 'mcpServers' | 'hooks'
|
|
18
|
+
|
|
19
|
+
type ExtensionFacetConfig = {
|
|
20
|
+
harnessId: HarnessId
|
|
21
|
+
context?: HarnessContext
|
|
22
|
+
configDirectory: string
|
|
23
|
+
skillsDirectory?: string
|
|
24
|
+
mcp?: CodexMcpConfig | ClaudeMcpConfig | KiroMcpConfig
|
|
25
|
+
hooks?: JsonHooksConfig | KiroHookFilesConfig
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type CodexMcpConfig = {
|
|
29
|
+
kind: 'codex-toml'
|
|
30
|
+
configFile: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ClaudeMcpConfig = {
|
|
34
|
+
kind: 'claude-json'
|
|
35
|
+
configFile: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type KiroMcpConfig = {
|
|
39
|
+
configFile: string
|
|
40
|
+
kind: 'kiro-cli'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type JsonHooksConfig = {
|
|
44
|
+
kind: 'json-hooks'
|
|
45
|
+
settingsFile: string
|
|
46
|
+
events: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type KiroHookFilesConfig = {
|
|
50
|
+
kind: 'kiro-hook-files'
|
|
51
|
+
hooksDirectory: string
|
|
52
|
+
events: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ExtensionState = {
|
|
56
|
+
extensions: Record<string, InstalledExtension>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type InstalledExtension = {
|
|
60
|
+
skills: InstalledSkill[]
|
|
61
|
+
mcpServers: InstalledMcpServer[]
|
|
62
|
+
hooks: InstalledHook[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type InstalledSkill = {
|
|
66
|
+
kind: 'directory-symlink' | 'file-symlink-directory'
|
|
67
|
+
sourcePath: string
|
|
68
|
+
targetPath: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type InstalledMcpServer = {
|
|
72
|
+
fingerprint: string
|
|
73
|
+
name: string
|
|
74
|
+
server?: McpServerResource
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type InstalledHook = {
|
|
78
|
+
command: string
|
|
79
|
+
event: string
|
|
80
|
+
fingerprint: string
|
|
81
|
+
name?: string
|
|
82
|
+
targetPath?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type JsonObject = Record<string, unknown>
|
|
86
|
+
|
|
87
|
+
const STATE_FILE = 'harness-extensions.json'
|
|
88
|
+
|
|
89
|
+
export function createExtensionFacet(config: ExtensionFacetConfig): ExtensionFacet {
|
|
90
|
+
return {
|
|
91
|
+
async check(extension: HarnessExtension) {
|
|
92
|
+
const issues = compatibilityIssues(config, extension)
|
|
93
|
+
return {
|
|
94
|
+
compatible: issues.length === 0,
|
|
95
|
+
issues
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async install(extension: HarnessExtension) {
|
|
100
|
+
return withConfigLock(config, async () => {
|
|
101
|
+
const state = await readState(config)
|
|
102
|
+
const owned = state.extensions[extension.id]
|
|
103
|
+
const issues = await preflight(config, extension, owned)
|
|
104
|
+
|
|
105
|
+
if (issues.length > 0) {
|
|
106
|
+
return extensionResult(issues)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await uninstallOwned(config, owned)
|
|
110
|
+
|
|
111
|
+
const next: InstalledExtension = { hooks: [], mcpServers: [], skills: [] }
|
|
112
|
+
try {
|
|
113
|
+
await installSkills(config, extension, next)
|
|
114
|
+
await installMcpServers(config, extension, next)
|
|
115
|
+
await installHooks(config, extension, next)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
await uninstallOwned(config, next)
|
|
118
|
+
await restoreOwned(config, extension.id, owned)
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (next.skills.length > 0 || next.mcpServers.length > 0 || next.hooks.length > 0) {
|
|
123
|
+
state.extensions[extension.id] = next
|
|
124
|
+
} else {
|
|
125
|
+
delete state.extensions[extension.id]
|
|
126
|
+
}
|
|
127
|
+
await writeState(config, state)
|
|
128
|
+
|
|
129
|
+
return extensionResult(issues)
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async uninstall(extensionId: string) {
|
|
134
|
+
return withConfigLock(config, async () => {
|
|
135
|
+
const state = await readState(config)
|
|
136
|
+
const owned = state.extensions[extensionId]
|
|
137
|
+
if (!owned) {
|
|
138
|
+
return extensionResult([])
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const issues = await ownershipConflicts(config, owned)
|
|
142
|
+
if (issues.length > 0) {
|
|
143
|
+
return extensionResult(issues)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await uninstallOwned(config, owned)
|
|
147
|
+
delete state.extensions[extensionId]
|
|
148
|
+
await writeState(config, state)
|
|
149
|
+
return extensionResult([])
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extensionResult(issues: ExtensionIssue[]): ExtensionResult {
|
|
156
|
+
return {
|
|
157
|
+
issues,
|
|
158
|
+
success: issues.length === 0
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function configDirectory(home: string | undefined, name: '.claude' | '.codex' | '.kiro' | '.pi/agent'): string {
|
|
163
|
+
return join(home ?? process.env.HOME ?? process.cwd(), name)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function preflight(
|
|
167
|
+
config: ExtensionFacetConfig,
|
|
168
|
+
extension: HarnessExtension,
|
|
169
|
+
owned: InstalledExtension | undefined
|
|
170
|
+
): Promise<ExtensionIssue[]> {
|
|
171
|
+
const issues: ExtensionIssue[] = []
|
|
172
|
+
|
|
173
|
+
issues.push(...(await ownershipConflicts(config, owned)))
|
|
174
|
+
issues.push(...unsupportedIssues(config, extension))
|
|
175
|
+
issues.push(...unsupportedHookEvents(config, extension))
|
|
176
|
+
issues.push(...(await skillConflicts(config, extension, owned)))
|
|
177
|
+
issues.push(...(await mcpConflicts(config, extension, owned)))
|
|
178
|
+
issues.push(...(await hookConflicts(config, extension, owned)))
|
|
179
|
+
|
|
180
|
+
return issues
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function ownershipConflicts(
|
|
184
|
+
config: ExtensionFacetConfig,
|
|
185
|
+
owned: InstalledExtension | undefined
|
|
186
|
+
): Promise<ExtensionIssue[]> {
|
|
187
|
+
if (!owned) {
|
|
188
|
+
return []
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
...(await ownedSkillConflicts(owned)),
|
|
193
|
+
...(await ownedMcpConflicts(config, owned)),
|
|
194
|
+
...(await ownedHookConflicts(config, owned))
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function compatibilityIssues(config: ExtensionFacetConfig, extension: HarnessExtension): ExtensionIssue[] {
|
|
199
|
+
return [...unsupportedIssues(config, extension), ...unsupportedHookEvents(config, extension)]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function unsupportedIssues(config: ExtensionFacetConfig, extension: HarnessExtension): ExtensionIssue[] {
|
|
203
|
+
const issues: ExtensionIssue[] = []
|
|
204
|
+
const supported = supportedResources(config)
|
|
205
|
+
|
|
206
|
+
if (!supported.has('skills')) {
|
|
207
|
+
for (const skill of extension.resources.skills ?? []) {
|
|
208
|
+
issues.push(unsupported(config.harnessId, 'skills', skill))
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!supported.has('mcpServers')) {
|
|
213
|
+
for (const name of Object.keys(extension.resources.mcpServers ?? {})) {
|
|
214
|
+
issues.push(unsupported(config.harnessId, 'mcpServers', name))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!supported.has('hooks')) {
|
|
219
|
+
for (const hook of extension.resources.hooks ?? []) {
|
|
220
|
+
issues.push(unsupported(config.harnessId, 'hooks', hook.name))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return issues
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function unsupportedHookEvents(config: ExtensionFacetConfig, extension: HarnessExtension): ExtensionIssue[] {
|
|
228
|
+
if (!config.hooks) {
|
|
229
|
+
return []
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const supported = new Set(config.hooks.events)
|
|
233
|
+
return (extension.resources.hooks ?? [])
|
|
234
|
+
.filter(hook => !supported.has(hook.event))
|
|
235
|
+
.map(hook => ({
|
|
236
|
+
kind: 'unsupported',
|
|
237
|
+
reason: `${config.harnessId} adapter does not support hook event ${hook.event}.`,
|
|
238
|
+
resourceKind: 'hooks',
|
|
239
|
+
resourceName: hook.name
|
|
240
|
+
}))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function supportedResources(config: ExtensionFacetConfig): Set<SupportedResource> {
|
|
244
|
+
const supported = new Set<SupportedResource>()
|
|
245
|
+
if (config.skillsDirectory) {
|
|
246
|
+
supported.add('skills')
|
|
247
|
+
}
|
|
248
|
+
if (config.mcp) {
|
|
249
|
+
supported.add('mcpServers')
|
|
250
|
+
}
|
|
251
|
+
if (config.hooks) {
|
|
252
|
+
supported.add('hooks')
|
|
253
|
+
}
|
|
254
|
+
return supported
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function unsupported(harnessId: HarnessId, resourceKind: ExtensionResourceKind, resourceName?: string): ExtensionIssue {
|
|
258
|
+
return {
|
|
259
|
+
kind: 'unsupported',
|
|
260
|
+
reason: `${harnessId} adapter does not support user-scope ${resourceKind} installation.`,
|
|
261
|
+
resourceKind,
|
|
262
|
+
resourceName
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function skillConflicts(
|
|
267
|
+
config: ExtensionFacetConfig,
|
|
268
|
+
extension: HarnessExtension,
|
|
269
|
+
owned: InstalledExtension | undefined
|
|
270
|
+
): Promise<ExtensionIssue[]> {
|
|
271
|
+
if (!config.skillsDirectory) {
|
|
272
|
+
return []
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const ownedTargets = new Set((owned?.skills ?? []).map(skill => skill.targetPath))
|
|
276
|
+
const issues: ExtensionIssue[] = []
|
|
277
|
+
|
|
278
|
+
for (const [index, skillPath] of (extension.resources.skills ?? []).entries()) {
|
|
279
|
+
const sourcePath = resolveExtensionPath(config, skillPath)
|
|
280
|
+
const targetPath = skillTargetPath(config.skillsDirectory, extension.id, skillPath, index)
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await lstat(sourcePath)
|
|
284
|
+
} catch {
|
|
285
|
+
issues.push({
|
|
286
|
+
kind: 'conflict',
|
|
287
|
+
reason: `Skill path does not exist: ${sourcePath}.`,
|
|
288
|
+
resourceKind: 'skills',
|
|
289
|
+
resourceName: skillPath
|
|
290
|
+
})
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (ownedTargets.has(targetPath)) {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (await pathExists(targetPath)) {
|
|
299
|
+
issues.push({
|
|
300
|
+
kind: 'conflict',
|
|
301
|
+
reason: `Skill install target already exists: ${targetPath}.`,
|
|
302
|
+
resourceKind: 'skills',
|
|
303
|
+
resourceName: skillPath
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return issues
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function ownedSkillConflicts(owned: InstalledExtension): Promise<ExtensionIssue[]> {
|
|
312
|
+
const issues: ExtensionIssue[] = []
|
|
313
|
+
|
|
314
|
+
for (const skill of owned.skills) {
|
|
315
|
+
const proof = await skillOwnershipProofMatches(skill)
|
|
316
|
+
if (proof === 'missing' || proof === 'matches') {
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
issues.push({
|
|
321
|
+
kind: 'conflict',
|
|
322
|
+
reason: `Skill install target is no longer owned by this extension: ${skill.targetPath}.`,
|
|
323
|
+
resourceKind: 'skills',
|
|
324
|
+
resourceName: skill.targetPath
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return issues
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function skillOwnershipProofMatches(skill: InstalledSkill): Promise<'matches' | 'missing' | 'mismatch'> {
|
|
332
|
+
const linkPath = skill.kind === 'file-symlink-directory' ? join(skill.targetPath, 'SKILL.md') : skill.targetPath
|
|
333
|
+
|
|
334
|
+
let linkStat: Awaited<ReturnType<typeof lstat>>
|
|
335
|
+
try {
|
|
336
|
+
linkStat = await lstat(linkPath)
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (isNotFound(error)) {
|
|
339
|
+
return 'missing'
|
|
340
|
+
}
|
|
341
|
+
throw error
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!linkStat.isSymbolicLink()) {
|
|
345
|
+
return 'mismatch'
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return (await readlink(linkPath)) === skill.sourcePath ? 'matches' : 'mismatch'
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function mcpConflicts(
|
|
352
|
+
config: ExtensionFacetConfig,
|
|
353
|
+
extension: HarnessExtension,
|
|
354
|
+
owned: InstalledExtension | undefined
|
|
355
|
+
): Promise<ExtensionIssue[]> {
|
|
356
|
+
if (!config.mcp) {
|
|
357
|
+
return []
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const ownedNames = new Set((owned?.mcpServers ?? []).map(server => server.name))
|
|
361
|
+
const issues: ExtensionIssue[] = []
|
|
362
|
+
|
|
363
|
+
for (const name of Object.keys(extension.resources.mcpServers ?? {})) {
|
|
364
|
+
if (ownedNames.has(name)) {
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (config.mcp.kind === 'kiro-cli' && (await jsonMcpServerExists(config.mcp.configFile, name))) {
|
|
369
|
+
issues.push(mcpConflict(name, config.mcp.configFile))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (config.mcp.kind === 'claude-json' && (await claudeMcpServerExists(config.mcp.configFile, name))) {
|
|
373
|
+
issues.push(mcpConflict(name, config.mcp.configFile))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (config.mcp.kind === 'codex-toml' && (await codexMcpServerExists(config.mcp.configFile, name))) {
|
|
377
|
+
issues.push(mcpConflict(name, config.mcp.configFile))
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return issues
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function ownedMcpConflicts(config: ExtensionFacetConfig, owned: InstalledExtension): Promise<ExtensionIssue[]> {
|
|
385
|
+
if (!config.mcp) {
|
|
386
|
+
return []
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const issues: ExtensionIssue[] = []
|
|
390
|
+
|
|
391
|
+
for (const server of owned.mcpServers) {
|
|
392
|
+
const current = await currentMcpFingerprint(config, server.name)
|
|
393
|
+
if (!current || current === server.fingerprint) {
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
issues.push({
|
|
398
|
+
kind: 'conflict',
|
|
399
|
+
reason: `MCP server ${server.name} is no longer owned by this extension.`,
|
|
400
|
+
resourceKind: 'mcpServers',
|
|
401
|
+
resourceName: server.name
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return issues
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function mcpConflict(name: string, configFile: string): ExtensionIssue {
|
|
409
|
+
return {
|
|
410
|
+
kind: 'conflict',
|
|
411
|
+
reason: `MCP server ${name} already exists in ${configFile}.`,
|
|
412
|
+
resourceKind: 'mcpServers',
|
|
413
|
+
resourceName: name
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function hookConflicts(
|
|
418
|
+
config: ExtensionFacetConfig,
|
|
419
|
+
extension: HarnessExtension,
|
|
420
|
+
owned: InstalledExtension | undefined
|
|
421
|
+
): Promise<ExtensionIssue[]> {
|
|
422
|
+
if (!config.hooks) {
|
|
423
|
+
return []
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const ownedKeys = new Set((owned?.hooks ?? []).map(hook => hookKey(hook)))
|
|
427
|
+
const issues: ExtensionIssue[] = []
|
|
428
|
+
|
|
429
|
+
for (const hook of extension.resources.hooks ?? []) {
|
|
430
|
+
if (!config.hooks.events.includes(hook.event) || ownedKeys.has(hookKey(hook))) {
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (config.hooks.kind === 'kiro-hook-files') {
|
|
435
|
+
const targetPath = kiroHookTargetPath(config.hooks.hooksDirectory, extension.id, hook)
|
|
436
|
+
if (await pathExists(targetPath)) {
|
|
437
|
+
issues.push({
|
|
438
|
+
kind: 'conflict',
|
|
439
|
+
reason: `Hook install target already exists: ${targetPath}.`,
|
|
440
|
+
resourceKind: 'hooks',
|
|
441
|
+
resourceName: hook.name
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const settings = await readJsonFile(config.hooks.settingsFile)
|
|
448
|
+
if (jsonHookCommandExists(settings, hook)) {
|
|
449
|
+
issues.push({
|
|
450
|
+
kind: 'conflict',
|
|
451
|
+
reason: `Hook command already exists for ${hook.event} in ${config.hooks.settingsFile}.`,
|
|
452
|
+
resourceKind: 'hooks',
|
|
453
|
+
resourceName: hook.name
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return issues
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function ownedHookConflicts(config: ExtensionFacetConfig, owned: InstalledExtension): Promise<ExtensionIssue[]> {
|
|
462
|
+
if (!config.hooks) {
|
|
463
|
+
return []
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const issues: ExtensionIssue[] = []
|
|
467
|
+
|
|
468
|
+
for (const hook of owned.hooks) {
|
|
469
|
+
const current = await currentHookFingerprint(config, hook)
|
|
470
|
+
if (!current || current === hook.fingerprint) {
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
issues.push({
|
|
475
|
+
kind: 'conflict',
|
|
476
|
+
reason: `Hook ${hook.event}/${hook.command} is no longer owned by this extension.`,
|
|
477
|
+
resourceKind: 'hooks',
|
|
478
|
+
resourceName: hook.targetPath ?? hook.command
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return issues
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function installSkills(
|
|
486
|
+
config: ExtensionFacetConfig,
|
|
487
|
+
extension: HarnessExtension,
|
|
488
|
+
installed: InstalledExtension
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
if (!config.skillsDirectory) {
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
for (const [index, skillPath] of (extension.resources.skills ?? []).entries()) {
|
|
495
|
+
const sourcePath = resolveExtensionPath(config, skillPath)
|
|
496
|
+
const sourceStat = await stat(sourcePath)
|
|
497
|
+
const targetPath = skillTargetPath(config.skillsDirectory, extension.id, skillPath, index)
|
|
498
|
+
|
|
499
|
+
await mkdir(dirname(targetPath), { recursive: true })
|
|
500
|
+
if (sourceStat.isDirectory()) {
|
|
501
|
+
await symlink(sourcePath, targetPath)
|
|
502
|
+
installed.skills.push({ kind: 'directory-symlink', sourcePath, targetPath })
|
|
503
|
+
} else {
|
|
504
|
+
await mkdir(targetPath, { recursive: true })
|
|
505
|
+
await symlink(sourcePath, join(targetPath, 'SKILL.md'))
|
|
506
|
+
installed.skills.push({ kind: 'file-symlink-directory', sourcePath, targetPath })
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function installMcpServers(
|
|
512
|
+
config: ExtensionFacetConfig,
|
|
513
|
+
extension: HarnessExtension,
|
|
514
|
+
installed: InstalledExtension
|
|
515
|
+
): Promise<void> {
|
|
516
|
+
if (!config.mcp) {
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const [name, server] of Object.entries(extension.resources.mcpServers ?? {})) {
|
|
521
|
+
if (config.mcp.kind === 'kiro-cli') {
|
|
522
|
+
installed.mcpServers.push(await installKiroMcpServer(config, name, server))
|
|
523
|
+
} else if (config.mcp.kind === 'claude-json') {
|
|
524
|
+
installed.mcpServers.push(await installClaudeMcpServer(config.mcp.configFile, name, server))
|
|
525
|
+
} else {
|
|
526
|
+
installed.mcpServers.push(await installCodexMcpServer(config.mcp.configFile, extension.id, name, server))
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function installHooks(
|
|
532
|
+
config: ExtensionFacetConfig,
|
|
533
|
+
extension: HarnessExtension,
|
|
534
|
+
installed: InstalledExtension
|
|
535
|
+
): Promise<void> {
|
|
536
|
+
if (!config.hooks) {
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (config.hooks.kind === 'kiro-hook-files') {
|
|
541
|
+
await installKiroHooks(config, extension, installed)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const settings = await readJsonFile(config.hooks.settingsFile)
|
|
546
|
+
const hooks = ensureObject(settings, 'hooks')
|
|
547
|
+
|
|
548
|
+
for (const hook of extension.resources.hooks ?? []) {
|
|
549
|
+
if (!config.hooks.events.includes(hook.event)) {
|
|
550
|
+
continue
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const eventHooks = ensureArray(hooks, hook.event)
|
|
554
|
+
const entry = {
|
|
555
|
+
hooks: [{ command: hook.command, type: 'command' }]
|
|
556
|
+
}
|
|
557
|
+
eventHooks.push(entry)
|
|
558
|
+
installed.hooks.push({
|
|
559
|
+
command: hook.command,
|
|
560
|
+
event: hook.event,
|
|
561
|
+
fingerprint: jsonFingerprint(entry),
|
|
562
|
+
name: hook.name
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await writeJsonFile(config.hooks.settingsFile, settings)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function uninstallOwned(config: ExtensionFacetConfig, owned: InstalledExtension | undefined): Promise<void> {
|
|
570
|
+
if (!owned) {
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
await uninstallSkills(owned)
|
|
575
|
+
await uninstallMcpServers(config, owned)
|
|
576
|
+
await uninstallHooks(config, owned)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function restoreOwned(
|
|
580
|
+
config: ExtensionFacetConfig,
|
|
581
|
+
extensionId: string,
|
|
582
|
+
owned: InstalledExtension | undefined
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
if (!owned) {
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await restoreSkills(owned)
|
|
589
|
+
await restoreMcpServers(config, extensionId, owned)
|
|
590
|
+
await restoreHooks(config, owned)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function restoreSkills(owned: InstalledExtension): Promise<void> {
|
|
594
|
+
for (const skill of owned.skills) {
|
|
595
|
+
if ((await skillOwnershipProofMatches(skill)) !== 'missing') {
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (await pathExists(skill.targetPath)) {
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
await mkdir(dirname(skill.targetPath), { recursive: true })
|
|
604
|
+
if (skill.kind === 'directory-symlink') {
|
|
605
|
+
await symlink(skill.sourcePath, skill.targetPath)
|
|
606
|
+
continue
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
await mkdir(skill.targetPath, { recursive: true })
|
|
610
|
+
await symlink(skill.sourcePath, join(skill.targetPath, 'SKILL.md'))
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function restoreMcpServers(
|
|
615
|
+
config: ExtensionFacetConfig,
|
|
616
|
+
extensionId: string,
|
|
617
|
+
owned: InstalledExtension
|
|
618
|
+
): Promise<void> {
|
|
619
|
+
if (!config.mcp) {
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
for (const server of owned.mcpServers) {
|
|
624
|
+
if (!server.server || (await currentMcpFingerprint(config, server.name))) {
|
|
625
|
+
continue
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (config.mcp.kind === 'kiro-cli') {
|
|
629
|
+
await installKiroMcpServer(config, server.name, server.server)
|
|
630
|
+
} else if (config.mcp.kind === 'claude-json') {
|
|
631
|
+
await installClaudeMcpServer(config.mcp.configFile, server.name, server.server)
|
|
632
|
+
} else {
|
|
633
|
+
await installCodexMcpServer(config.mcp.configFile, extensionId, server.name, server.server)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function restoreHooks(config: ExtensionFacetConfig, owned: InstalledExtension): Promise<void> {
|
|
639
|
+
if (!config.hooks) {
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (config.hooks.kind === 'kiro-hook-files') {
|
|
644
|
+
for (const hook of owned.hooks) {
|
|
645
|
+
if (!hook.targetPath || !hook.name || (await pathExists(hook.targetPath))) {
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
await writeJsonFile(
|
|
650
|
+
hook.targetPath,
|
|
651
|
+
kiroHookConfig({ command: hook.command, event: hook.event, name: hook.name })
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const settings = await readJsonFile(config.hooks.settingsFile)
|
|
658
|
+
const hooks = ensureObject(settings, 'hooks')
|
|
659
|
+
|
|
660
|
+
for (const hook of owned.hooks) {
|
|
661
|
+
if (await currentHookFingerprint(config, hook)) {
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
ensureArray(hooks, hook.event).push({
|
|
666
|
+
hooks: [{ command: hook.command, type: 'command' }]
|
|
667
|
+
})
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
await writeJsonFile(config.hooks.settingsFile, settings)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function uninstallSkills(owned: InstalledExtension): Promise<void> {
|
|
674
|
+
for (const skill of owned.skills) {
|
|
675
|
+
const proof = await skillOwnershipProofMatches(skill)
|
|
676
|
+
if (proof !== 'matches') {
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (skill.kind === 'directory-symlink') {
|
|
681
|
+
await rm(skill.targetPath, { force: true })
|
|
682
|
+
continue
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
await rm(join(skill.targetPath, 'SKILL.md'), { force: true })
|
|
686
|
+
try {
|
|
687
|
+
await rmdir(skill.targetPath)
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (!isNotFound(error) && !isNotEmpty(error)) {
|
|
690
|
+
throw error
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function uninstallMcpServers(config: ExtensionFacetConfig, owned: InstalledExtension): Promise<void> {
|
|
697
|
+
if (!config.mcp) {
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
for (const server of owned.mcpServers) {
|
|
702
|
+
const current = await currentMcpFingerprint(config, server.name)
|
|
703
|
+
if (!current) {
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (config.mcp.kind === 'kiro-cli') {
|
|
708
|
+
await removeKiroMcpServer(config, server.name)
|
|
709
|
+
} else if (config.mcp.kind === 'claude-json') {
|
|
710
|
+
await removeClaudeMcpServer(config.mcp.configFile, server.name)
|
|
711
|
+
} else {
|
|
712
|
+
await removeCodexMcpServer(config.mcp.configFile, server.name)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function uninstallHooks(config: ExtensionFacetConfig, owned: InstalledExtension): Promise<void> {
|
|
718
|
+
if (!config.hooks || owned.hooks.length === 0) {
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (config.hooks.kind === 'kiro-hook-files') {
|
|
723
|
+
await uninstallKiroHooks(owned)
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const settings = await readJsonFile(config.hooks.settingsFile)
|
|
728
|
+
const hooks = objectValue(settings.hooks)
|
|
729
|
+
if (!hooks) {
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
for (const hook of owned.hooks) {
|
|
734
|
+
const entries = arrayValue(hooks[hook.event])
|
|
735
|
+
if (!entries) {
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
hooks[hook.event] = entries.filter(entry => jsonFingerprint(entry) !== hook.fingerprint)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
await writeJsonFile(config.hooks.settingsFile, settings)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function installKiroHooks(
|
|
746
|
+
config: ExtensionFacetConfig,
|
|
747
|
+
extension: HarnessExtension,
|
|
748
|
+
installed: InstalledExtension
|
|
749
|
+
): Promise<void> {
|
|
750
|
+
if (config.hooks?.kind !== 'kiro-hook-files') {
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
for (const hook of extension.resources.hooks ?? []) {
|
|
755
|
+
if (!config.hooks.events.includes(hook.event)) {
|
|
756
|
+
continue
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const targetPath = kiroHookTargetPath(config.hooks.hooksDirectory, extension.id, hook)
|
|
760
|
+
const hookConfig = kiroHookConfig(hook)
|
|
761
|
+
await writeJsonFile(targetPath, hookConfig)
|
|
762
|
+
installed.hooks.push({
|
|
763
|
+
command: hook.command,
|
|
764
|
+
event: hook.event,
|
|
765
|
+
fingerprint: jsonFingerprint(hookConfig),
|
|
766
|
+
name: hook.name,
|
|
767
|
+
targetPath
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function kiroHookConfig(hook: HookResource): JsonObject {
|
|
773
|
+
return {
|
|
774
|
+
version: 'v1',
|
|
775
|
+
hooks: [
|
|
776
|
+
{
|
|
777
|
+
action: { command: hook.command, type: 'command' },
|
|
778
|
+
enabled: true,
|
|
779
|
+
name: hook.name,
|
|
780
|
+
trigger: hook.event
|
|
781
|
+
}
|
|
782
|
+
]
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function uninstallKiroHooks(owned: InstalledExtension): Promise<void> {
|
|
787
|
+
for (const hook of owned.hooks) {
|
|
788
|
+
if (hook.targetPath) {
|
|
789
|
+
const current = await readJsonFileFingerprint(hook.targetPath)
|
|
790
|
+
if (current !== hook.fingerprint) {
|
|
791
|
+
continue
|
|
792
|
+
}
|
|
793
|
+
await rm(hook.targetPath, { force: true })
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function installClaudeMcpServer(
|
|
799
|
+
configFile: string,
|
|
800
|
+
name: string,
|
|
801
|
+
server: McpServerResource
|
|
802
|
+
): Promise<InstalledMcpServer> {
|
|
803
|
+
const config = await readJsonFile(configFile)
|
|
804
|
+
const mcpServers = ensureObject(config, 'mcpServers')
|
|
805
|
+
mcpServers[name] = {
|
|
806
|
+
args: server.args ?? [],
|
|
807
|
+
command: server.command,
|
|
808
|
+
...(server.env ? { env: server.env } : {})
|
|
809
|
+
}
|
|
810
|
+
await writeJsonFile(configFile, config)
|
|
811
|
+
return { fingerprint: jsonFingerprint(mcpServers[name]), name, server: mcpServerRecord(server) }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function mcpServerRecord(server: McpServerResource): McpServerResource {
|
|
815
|
+
return {
|
|
816
|
+
args: server.args ?? [],
|
|
817
|
+
command: server.command,
|
|
818
|
+
...(server.env ? { env: server.env } : {})
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function removeClaudeMcpServer(configFile: string, name: string): Promise<void> {
|
|
823
|
+
const config = await readJsonFile(configFile)
|
|
824
|
+
const mcpServers = objectValue(config.mcpServers)
|
|
825
|
+
if (mcpServers) {
|
|
826
|
+
delete mcpServers[name]
|
|
827
|
+
}
|
|
828
|
+
await writeJsonFile(configFile, config)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function claudeMcpServerExists(configFile: string, name: string): Promise<boolean> {
|
|
832
|
+
return jsonMcpServerExists(configFile, name)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function jsonMcpServerExists(configFile: string, name: string): Promise<boolean> {
|
|
836
|
+
const config = await readJsonFile(configFile)
|
|
837
|
+
return objectValue(config.mcpServers)?.[name] !== undefined
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function currentMcpFingerprint(config: ExtensionFacetConfig, name: string): Promise<string | undefined> {
|
|
841
|
+
if (!config.mcp) {
|
|
842
|
+
return undefined
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (config.mcp.kind === 'codex-toml') {
|
|
846
|
+
const block = codexMcpServerBlock(await readTextFile(config.mcp.configFile), name)
|
|
847
|
+
return block ? textFingerprint(block) : undefined
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const mcpConfig = await readJsonFile(config.mcp.configFile)
|
|
851
|
+
const server = objectValue(mcpConfig.mcpServers)?.[name]
|
|
852
|
+
return server === undefined ? undefined : jsonFingerprint(server)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function installKiroMcpServer(
|
|
856
|
+
config: ExtensionFacetConfig,
|
|
857
|
+
name: string,
|
|
858
|
+
server: McpServerResource
|
|
859
|
+
): Promise<InstalledMcpServer> {
|
|
860
|
+
const args = ['mcp', 'add', '--scope', 'global', '--name', name, '--command', server.command]
|
|
861
|
+
|
|
862
|
+
if (server.args && server.args.length > 0) {
|
|
863
|
+
args.push('--args', JSON.stringify(server.args))
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
for (const [key, value] of Object.entries(server.env ?? {})) {
|
|
867
|
+
args.push('--env', `${key}=${value}`)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
await runKiroCommand(config, args)
|
|
871
|
+
|
|
872
|
+
const fingerprint = await currentMcpFingerprint(config, name)
|
|
873
|
+
if (!fingerprint) {
|
|
874
|
+
await removeKiroMcpServer(config, name)
|
|
875
|
+
throw new Error(`Kiro MCP server ${name} was not written to ${config.mcp?.configFile}.`)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return { fingerprint, name, server: mcpServerRecord(server) }
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function removeKiroMcpServer(config: ExtensionFacetConfig, name: string): Promise<void> {
|
|
882
|
+
await runKiroCommand(config, ['mcp', 'remove', '--scope', 'global', '--name', name])
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function installCodexMcpServer(
|
|
886
|
+
configFile: string,
|
|
887
|
+
extensionId: string,
|
|
888
|
+
name: string,
|
|
889
|
+
server: McpServerResource
|
|
890
|
+
): Promise<InstalledMcpServer> {
|
|
891
|
+
const current = await readTextFile(configFile)
|
|
892
|
+
const block = codexMcpBlock(extensionId, name, server)
|
|
893
|
+
const next = `${removeCodexMcpServerBlock(current, name).trimEnd()}\n\n${block}\n`
|
|
894
|
+
await writeTextFile(configFile, next)
|
|
895
|
+
return { fingerprint: textFingerprint(block), name, server: mcpServerRecord(server) }
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function removeCodexMcpServer(configFile: string, name: string): Promise<void> {
|
|
899
|
+
const current = await readTextFile(configFile)
|
|
900
|
+
await writeTextFile(configFile, `${removeCodexMcpServerBlock(current, name).trimEnd()}\n`)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function codexMcpServerExists(configFile: string, name: string): Promise<boolean> {
|
|
904
|
+
const config = await readTextFile(configFile)
|
|
905
|
+
return codexMcpHeaderPattern(name).test(config)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function codexMcpBlock(extensionId: string, name: string, server: McpServerResource): string {
|
|
909
|
+
const lines = [
|
|
910
|
+
codexBeginMarker(name),
|
|
911
|
+
`[mcp_servers.${tomlKey(name)}]`,
|
|
912
|
+
`command = ${tomlString(server.command)}`,
|
|
913
|
+
`args = ${tomlArray(server.args ?? [])}`
|
|
914
|
+
]
|
|
915
|
+
|
|
916
|
+
const env = server.env ?? {}
|
|
917
|
+
if (Object.keys(env).length > 0) {
|
|
918
|
+
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`)
|
|
919
|
+
for (const [key, value] of Object.entries(env)) {
|
|
920
|
+
lines.push(`${tomlKey(key)} = ${tomlString(value)}`)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
lines.push(`# @plimeor/harness extension = ${extensionId}`, codexEndMarker(name))
|
|
925
|
+
return lines.join('\n')
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function removeCodexMcpServerBlock(config: string, name: string): string {
|
|
929
|
+
const begin = escapeRegExp(codexBeginMarker(name))
|
|
930
|
+
const end = escapeRegExp(codexEndMarker(name))
|
|
931
|
+
return config.replace(new RegExp(`\\n?${begin}[\\s\\S]*?${end}\\n?`, 'g'), '\n')
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function codexMcpServerBlock(config: string, name: string): string | undefined {
|
|
935
|
+
const begin = escapeRegExp(codexBeginMarker(name))
|
|
936
|
+
const end = escapeRegExp(codexEndMarker(name))
|
|
937
|
+
return config.match(new RegExp(`${begin}[\\s\\S]*?${end}`))?.[0]
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function codexMcpHeaderPattern(name: string): RegExp {
|
|
941
|
+
const unquoted = escapeRegExp(`[mcp_servers.${name}]`)
|
|
942
|
+
const quoted = escapeRegExp(`[mcp_servers.${tomlKey(name)}]`)
|
|
943
|
+
return new RegExp(`(^|\\n)(${unquoted}|${quoted})(\\n|$)`)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function codexBeginMarker(name: string): string {
|
|
947
|
+
return `# @plimeor/harness begin mcpServers ${name}`
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function codexEndMarker(name: string): string {
|
|
951
|
+
return `# @plimeor/harness end mcpServers ${name}`
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function jsonHookCommandExists(settings: JsonObject, hook: HookResource): boolean {
|
|
955
|
+
const hooks = objectValue(settings.hooks)
|
|
956
|
+
const eventHooks = arrayValue(hooks?.[hook.event])
|
|
957
|
+
return eventHooks?.some(entry => claudeHookCommands(entry).includes(hook.command)) ?? false
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function claudeHookCommands(entry: unknown): string[] {
|
|
961
|
+
const group = objectValue(entry)
|
|
962
|
+
const commands = arrayValue(group?.hooks) ?? []
|
|
963
|
+
return commands.flatMap(candidate => {
|
|
964
|
+
const hook = objectValue(candidate)
|
|
965
|
+
return hook?.type === 'command' && typeof hook.command === 'string' ? [hook.command] : []
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function currentHookFingerprint(config: ExtensionFacetConfig, hook: InstalledHook): Promise<string | undefined> {
|
|
970
|
+
if (config.hooks?.kind === 'kiro-hook-files') {
|
|
971
|
+
return hook.targetPath ? readJsonFileFingerprint(hook.targetPath) : undefined
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (!config.hooks) {
|
|
975
|
+
return undefined
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const settings = await readJsonFile(config.hooks.settingsFile)
|
|
979
|
+
const hooks = objectValue(settings.hooks)
|
|
980
|
+
const entries = arrayValue(hooks?.[hook.event]) ?? []
|
|
981
|
+
const exactMatches = entries.filter(entry => jsonFingerprint(entry) === hook.fingerprint)
|
|
982
|
+
|
|
983
|
+
if (exactMatches.length === 1) {
|
|
984
|
+
return hook.fingerprint
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (exactMatches.length > 1 || entries.some(entry => claudeHookCommands(entry).includes(hook.command))) {
|
|
988
|
+
return `${hook.fingerprint}:mismatch`
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return undefined
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function readState(config: ExtensionFacetConfig): Promise<ExtensionState> {
|
|
995
|
+
const state = await readJsonFile(join(config.configDirectory, STATE_FILE))
|
|
996
|
+
return { extensions: (objectValue(state.extensions) as Record<string, InstalledExtension> | undefined) ?? {} }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function writeState(config: ExtensionFacetConfig, state: ExtensionState): Promise<void> {
|
|
1000
|
+
await writeJsonFile(join(config.configDirectory, STATE_FILE), state)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function resolveExtensionPath(config: ExtensionFacetConfig, path: string): string {
|
|
1004
|
+
return isAbsolute(path) ? path : resolve(config.context?.cwd ?? process.cwd(), path)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function skillTargetPath(skillsDirectory: string, extensionId: string, skillPath: string, index: number): string {
|
|
1008
|
+
return join(skillsDirectory, `${safeName(extensionId)}__${index}_${safeName(skillBaseName(skillPath))}`)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function kiroHookTargetPath(hooksDirectory: string, extensionId: string, hook: HookResource): string {
|
|
1012
|
+
return join(hooksDirectory, `${safeName(extensionId)}__${safeName(hook.name)}.json`)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function skillBaseName(path: string): string {
|
|
1016
|
+
const base = basename(path)
|
|
1017
|
+
const extension = extname(base)
|
|
1018
|
+
return extension ? base.slice(0, -extension.length) : base
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function safeName(value: string): string {
|
|
1022
|
+
return value.replaceAll(/[^A-Za-z0-9._-]/g, '_')
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function hookKey(hook: Pick<InstalledHook, 'command' | 'event'>): string {
|
|
1026
|
+
return `${hook.event}\u0000${hook.command}`
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
1030
|
+
try {
|
|
1031
|
+
await lstat(path)
|
|
1032
|
+
return true
|
|
1033
|
+
} catch {
|
|
1034
|
+
return false
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function readJsonFile(path: string): Promise<JsonObject> {
|
|
1039
|
+
try {
|
|
1040
|
+
const text = await readFile(path, 'utf8')
|
|
1041
|
+
const value = JSON.parse(text) as unknown
|
|
1042
|
+
return objectValue(value) ?? {}
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
if (isNotFound(error)) {
|
|
1045
|
+
return {}
|
|
1046
|
+
}
|
|
1047
|
+
throw error
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function writeJsonFile(path: string, value: unknown): Promise<void> {
|
|
1052
|
+
await mkdir(dirname(path), { recursive: true })
|
|
1053
|
+
await writeFileAtomically(path, `${JSON.stringify(value, null, 2)}\n`)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function readTextFile(path: string): Promise<string> {
|
|
1057
|
+
try {
|
|
1058
|
+
return await readFile(path, 'utf8')
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
if (isNotFound(error)) {
|
|
1061
|
+
return ''
|
|
1062
|
+
}
|
|
1063
|
+
throw error
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function writeTextFile(path: string, text: string): Promise<void> {
|
|
1068
|
+
await mkdir(dirname(path), { recursive: true })
|
|
1069
|
+
await writeFileAtomically(path, text)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function writeFileAtomically(path: string, text: string): Promise<void> {
|
|
1073
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`)
|
|
1074
|
+
await writeFile(temporaryPath, text)
|
|
1075
|
+
try {
|
|
1076
|
+
await rename(temporaryPath, path)
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
await rm(temporaryPath, { force: true })
|
|
1079
|
+
throw error
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
async function withConfigLock<T>(config: ExtensionFacetConfig, operation: () => Promise<T>): Promise<T> {
|
|
1084
|
+
await mkdir(config.configDirectory, { recursive: true })
|
|
1085
|
+
const lockPath = join(config.configDirectory, '.harness-extensions.lock')
|
|
1086
|
+
const handle = await acquireLock(lockPath)
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
return await operation()
|
|
1090
|
+
} finally {
|
|
1091
|
+
await handle.close()
|
|
1092
|
+
await rm(lockPath, { force: true })
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
|
|
1097
|
+
const startedAt = Date.now()
|
|
1098
|
+
|
|
1099
|
+
while (Date.now() - startedAt < 5_000) {
|
|
1100
|
+
try {
|
|
1101
|
+
return await open(path, 'wx')
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
if (!isAlreadyExists(error)) {
|
|
1104
|
+
throw error
|
|
1105
|
+
}
|
|
1106
|
+
await Bun.sleep(50)
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
throw new Error(`Timed out waiting for extension config lock: ${path}`)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
async function runKiroCommand(
|
|
1114
|
+
config: ExtensionFacetConfig,
|
|
1115
|
+
args: string[]
|
|
1116
|
+
): Promise<{ exitCode: number; stderr: string; stdout: string }> {
|
|
1117
|
+
const subprocess = Bun.spawn({
|
|
1118
|
+
cmd: ['kiro-cli', ...args],
|
|
1119
|
+
cwd: config.context?.cwd ?? process.cwd(),
|
|
1120
|
+
env: Object.fromEntries(
|
|
1121
|
+
Object.entries({
|
|
1122
|
+
...process.env,
|
|
1123
|
+
...(config.context?.home ? { KIRO_HOME: config.configDirectory } : {}),
|
|
1124
|
+
...(config.context?.env ?? {})
|
|
1125
|
+
}).filter((entry): entry is [string, string] => {
|
|
1126
|
+
return typeof entry[1] === 'string'
|
|
1127
|
+
})
|
|
1128
|
+
),
|
|
1129
|
+
stderr: 'pipe',
|
|
1130
|
+
stdout: 'pipe'
|
|
1131
|
+
})
|
|
1132
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
1133
|
+
subprocess.exited,
|
|
1134
|
+
new Response(subprocess.stdout).text(),
|
|
1135
|
+
new Response(subprocess.stderr).text()
|
|
1136
|
+
])
|
|
1137
|
+
|
|
1138
|
+
if (exitCode !== 0) {
|
|
1139
|
+
throw new Error(`kiro-cli ${args.join(' ')} failed: ${stderr || stdout}`)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return { exitCode, stderr, stdout }
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function ensureObject(parent: JsonObject, key: string): JsonObject {
|
|
1146
|
+
const current = objectValue(parent[key])
|
|
1147
|
+
if (current) {
|
|
1148
|
+
return current
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const next: JsonObject = {}
|
|
1152
|
+
parent[key] = next
|
|
1153
|
+
return next
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function ensureArray(parent: JsonObject, key: string): unknown[] {
|
|
1157
|
+
const current = arrayValue(parent[key])
|
|
1158
|
+
if (current) {
|
|
1159
|
+
return current
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const next: unknown[] = []
|
|
1163
|
+
parent[key] = next
|
|
1164
|
+
return next
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function objectValue(value: unknown): JsonObject | undefined {
|
|
1168
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : undefined
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function arrayValue(value: unknown): unknown[] | undefined {
|
|
1172
|
+
return Array.isArray(value) ? value : undefined
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function tomlKey(value: string): string {
|
|
1176
|
+
return /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlString(value)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function tomlArray(values: string[]): string {
|
|
1180
|
+
return `[${values.map(tomlString).join(', ')}]`
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function tomlString(value: string): string {
|
|
1184
|
+
return JSON.stringify(value)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function jsonFingerprint(value: unknown): string {
|
|
1188
|
+
return textFingerprint(stableJson(value))
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function textFingerprint(value: string): string {
|
|
1192
|
+
return createHash('sha256').update(value).digest('hex')
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async function readJsonFileFingerprint(path: string): Promise<string | undefined> {
|
|
1196
|
+
try {
|
|
1197
|
+
return jsonFingerprint(await readJsonFile(path))
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
if (isNotFound(error)) {
|
|
1200
|
+
return undefined
|
|
1201
|
+
}
|
|
1202
|
+
throw error
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function stableJson(value: unknown): string {
|
|
1207
|
+
if (Array.isArray(value)) {
|
|
1208
|
+
return `[${value.map(stableJson).join(',')}]`
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (value && typeof value === 'object') {
|
|
1212
|
+
return `{${Object.entries(value as JsonObject)
|
|
1213
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1214
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`)
|
|
1215
|
+
.join(',')}}`
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
return JSON.stringify(value)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function escapeRegExp(value: string): string {
|
|
1222
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function isAlreadyExists(error: unknown): boolean {
|
|
1226
|
+
return error instanceof Error && 'code' in error && error.code === 'EEXIST'
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function isNotFound(error: unknown): boolean {
|
|
1230
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function isNotEmpty(error: unknown): boolean {
|
|
1234
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOTEMPTY'
|
|
1235
|
+
}
|