@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.
@@ -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
+ }