@plimeor/harness 0.1.0 → 0.1.2
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/package.json +1 -1
- package/src/adapters/claude.ts +35 -39
- package/src/adapters/codex-extensions.ts +266 -0
- package/src/adapters/codex.ts +16 -19
- package/src/adapters/extensions.ts +168 -450
- package/src/adapters/kiro-extensions.ts +182 -0
- package/src/adapters/kiro.ts +20 -19
|
@@ -21,35 +21,25 @@ type ExtensionFacetConfig = {
|
|
|
21
21
|
context?: HarnessContext
|
|
22
22
|
configDirectory: string
|
|
23
23
|
skillsDirectory?: string
|
|
24
|
-
mcp?:
|
|
25
|
-
hooks?:
|
|
24
|
+
mcp?: McpExtensionDriver
|
|
25
|
+
hooks?: HookExtensionDriver
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
type
|
|
29
|
-
kind: 'codex-toml'
|
|
28
|
+
export type McpExtensionDriver = {
|
|
30
29
|
configFile: string
|
|
30
|
+
canReclaimOnInstall?(input: { extension: HarnessExtension; name: string }): boolean | Promise<boolean>
|
|
31
|
+
currentFingerprint(name: string): Promise<string | undefined>
|
|
32
|
+
install(input: { extensionId: string; name: string; server: McpServerResource }): Promise<InstalledMcpServer>
|
|
33
|
+
remove(name: string): Promise<void>
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
type
|
|
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
|
|
36
|
+
export type HookExtensionDriver = {
|
|
52
37
|
events: readonly string[]
|
|
38
|
+
conflicts(input: { extensionId: string; hooks: HookResource[] }): Promise<ExtensionIssue[]>
|
|
39
|
+
currentFingerprint(hook: InstalledHook): Promise<string | undefined>
|
|
40
|
+
install(input: { extensionId: string; hooks: HookResource[] }): Promise<InstalledHook[]>
|
|
41
|
+
restore(hooks: InstalledHook[]): Promise<void>
|
|
42
|
+
uninstall(hooks: InstalledHook[]): Promise<void>
|
|
53
43
|
}
|
|
54
44
|
|
|
55
45
|
type ExtensionState = {
|
|
@@ -68,13 +58,13 @@ type InstalledSkill = {
|
|
|
68
58
|
targetPath: string
|
|
69
59
|
}
|
|
70
60
|
|
|
71
|
-
type InstalledMcpServer = {
|
|
61
|
+
export type InstalledMcpServer = {
|
|
72
62
|
fingerprint: string
|
|
73
63
|
name: string
|
|
74
64
|
server?: McpServerResource
|
|
75
65
|
}
|
|
76
66
|
|
|
77
|
-
type InstalledHook = {
|
|
67
|
+
export type InstalledHook = {
|
|
78
68
|
command: string
|
|
79
69
|
event: string
|
|
80
70
|
fingerprint: string
|
|
@@ -82,7 +72,7 @@ type InstalledHook = {
|
|
|
82
72
|
targetPath?: string
|
|
83
73
|
}
|
|
84
74
|
|
|
85
|
-
type JsonObject = Record<string, unknown>
|
|
75
|
+
export type JsonObject = Record<string, unknown>
|
|
86
76
|
|
|
87
77
|
const STATE_FILE = 'harness-extensions.json'
|
|
88
78
|
|
|
@@ -170,7 +160,7 @@ async function preflight(
|
|
|
170
160
|
): Promise<ExtensionIssue[]> {
|
|
171
161
|
const issues: ExtensionIssue[] = []
|
|
172
162
|
|
|
173
|
-
issues.push(...(await ownershipConflicts(config, owned)))
|
|
163
|
+
issues.push(...(await ownershipConflicts(config, owned, extension)))
|
|
174
164
|
issues.push(...unsupportedIssues(config, extension))
|
|
175
165
|
issues.push(...unsupportedHookEvents(config, extension))
|
|
176
166
|
issues.push(...(await skillConflicts(config, extension, owned)))
|
|
@@ -182,7 +172,8 @@ async function preflight(
|
|
|
182
172
|
|
|
183
173
|
async function ownershipConflicts(
|
|
184
174
|
config: ExtensionFacetConfig,
|
|
185
|
-
owned: InstalledExtension | undefined
|
|
175
|
+
owned: InstalledExtension | undefined,
|
|
176
|
+
extension?: HarnessExtension
|
|
186
177
|
): Promise<ExtensionIssue[]> {
|
|
187
178
|
if (!owned) {
|
|
188
179
|
return []
|
|
@@ -190,7 +181,7 @@ async function ownershipConflicts(
|
|
|
190
181
|
|
|
191
182
|
return [
|
|
192
183
|
...(await ownedSkillConflicts(owned)),
|
|
193
|
-
...(await ownedMcpConflicts(config, owned)),
|
|
184
|
+
...(await ownedMcpConflicts(config, owned, extension)),
|
|
194
185
|
...(await ownedHookConflicts(config, owned))
|
|
195
186
|
]
|
|
196
187
|
}
|
|
@@ -365,15 +356,7 @@ async function mcpConflicts(
|
|
|
365
356
|
continue
|
|
366
357
|
}
|
|
367
358
|
|
|
368
|
-
if (
|
|
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))) {
|
|
359
|
+
if (await config.mcp.currentFingerprint(name)) {
|
|
377
360
|
issues.push(mcpConflict(name, config.mcp.configFile))
|
|
378
361
|
}
|
|
379
362
|
}
|
|
@@ -381,7 +364,11 @@ async function mcpConflicts(
|
|
|
381
364
|
return issues
|
|
382
365
|
}
|
|
383
366
|
|
|
384
|
-
async function ownedMcpConflicts(
|
|
367
|
+
async function ownedMcpConflicts(
|
|
368
|
+
config: ExtensionFacetConfig,
|
|
369
|
+
owned: InstalledExtension,
|
|
370
|
+
extension?: HarnessExtension
|
|
371
|
+
): Promise<ExtensionIssue[]> {
|
|
385
372
|
if (!config.mcp) {
|
|
386
373
|
return []
|
|
387
374
|
}
|
|
@@ -389,11 +376,21 @@ async function ownedMcpConflicts(config: ExtensionFacetConfig, owned: InstalledE
|
|
|
389
376
|
const issues: ExtensionIssue[] = []
|
|
390
377
|
|
|
391
378
|
for (const server of owned.mcpServers) {
|
|
392
|
-
const current = await
|
|
379
|
+
const current = await config.mcp.currentFingerprint(server.name)
|
|
393
380
|
if (!current || current === server.fingerprint) {
|
|
394
381
|
continue
|
|
395
382
|
}
|
|
396
383
|
|
|
384
|
+
if (
|
|
385
|
+
extension &&
|
|
386
|
+
(await config.mcp.canReclaimOnInstall?.({
|
|
387
|
+
extension,
|
|
388
|
+
name: server.name
|
|
389
|
+
}))
|
|
390
|
+
) {
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
|
|
397
394
|
issues.push({
|
|
398
395
|
kind: 'conflict',
|
|
399
396
|
reason: `MCP server ${server.name} is no longer owned by this extension.`,
|
|
@@ -424,38 +421,11 @@ async function hookConflicts(
|
|
|
424
421
|
}
|
|
425
422
|
|
|
426
423
|
const ownedKeys = new Set((owned?.hooks ?? []).map(hook => hookKey(hook)))
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
}
|
|
424
|
+
const hooks = (extension.resources.hooks ?? []).filter(hook => {
|
|
425
|
+
return config.hooks?.events.includes(hook.event) && !ownedKeys.has(hookKey(hook))
|
|
426
|
+
})
|
|
457
427
|
|
|
458
|
-
return
|
|
428
|
+
return config.hooks.conflicts({ extensionId: extension.id, hooks })
|
|
459
429
|
}
|
|
460
430
|
|
|
461
431
|
async function ownedHookConflicts(config: ExtensionFacetConfig, owned: InstalledExtension): Promise<ExtensionIssue[]> {
|
|
@@ -466,7 +436,7 @@ async function ownedHookConflicts(config: ExtensionFacetConfig, owned: Installed
|
|
|
466
436
|
const issues: ExtensionIssue[] = []
|
|
467
437
|
|
|
468
438
|
for (const hook of owned.hooks) {
|
|
469
|
-
const current = await
|
|
439
|
+
const current = await config.hooks.currentFingerprint(hook)
|
|
470
440
|
if (!current || current === hook.fingerprint) {
|
|
471
441
|
continue
|
|
472
442
|
}
|
|
@@ -518,13 +488,7 @@ async function installMcpServers(
|
|
|
518
488
|
}
|
|
519
489
|
|
|
520
490
|
for (const [name, server] of Object.entries(extension.resources.mcpServers ?? {})) {
|
|
521
|
-
|
|
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
|
-
}
|
|
491
|
+
installed.mcpServers.push(await config.mcp.install({ extensionId: extension.id, name, server }))
|
|
528
492
|
}
|
|
529
493
|
}
|
|
530
494
|
|
|
@@ -537,33 +501,12 @@ async function installHooks(
|
|
|
537
501
|
return
|
|
538
502
|
}
|
|
539
503
|
|
|
540
|
-
|
|
541
|
-
|
|
504
|
+
const hooks = (extension.resources.hooks ?? []).filter(hook => config.hooks?.events.includes(hook.event))
|
|
505
|
+
if (hooks.length === 0) {
|
|
542
506
|
return
|
|
543
507
|
}
|
|
544
508
|
|
|
545
|
-
|
|
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)
|
|
509
|
+
installed.hooks.push(...(await config.hooks.install({ extensionId: extension.id, hooks })))
|
|
567
510
|
}
|
|
568
511
|
|
|
569
512
|
async function uninstallOwned(config: ExtensionFacetConfig, owned: InstalledExtension | undefined): Promise<void> {
|
|
@@ -621,53 +564,20 @@ async function restoreMcpServers(
|
|
|
621
564
|
}
|
|
622
565
|
|
|
623
566
|
for (const server of owned.mcpServers) {
|
|
624
|
-
if (!server.server || (await
|
|
567
|
+
if (!server.server || (await config.mcp.currentFingerprint(server.name))) {
|
|
625
568
|
continue
|
|
626
569
|
}
|
|
627
570
|
|
|
628
|
-
|
|
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
|
-
}
|
|
571
|
+
await config.mcp.install({ extensionId, name: server.name, server: server.server })
|
|
635
572
|
}
|
|
636
573
|
}
|
|
637
574
|
|
|
638
575
|
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
|
-
}
|
|
576
|
+
if (!config.hooks || owned.hooks.length === 0) {
|
|
654
577
|
return
|
|
655
578
|
}
|
|
656
579
|
|
|
657
|
-
|
|
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)
|
|
580
|
+
await config.hooks.restore(owned.hooks)
|
|
671
581
|
}
|
|
672
582
|
|
|
673
583
|
async function uninstallSkills(owned: InstalledExtension): Promise<void> {
|
|
@@ -699,18 +609,12 @@ async function uninstallMcpServers(config: ExtensionFacetConfig, owned: Installe
|
|
|
699
609
|
}
|
|
700
610
|
|
|
701
611
|
for (const server of owned.mcpServers) {
|
|
702
|
-
const current = await
|
|
612
|
+
const current = await config.mcp.currentFingerprint(server.name)
|
|
703
613
|
if (!current) {
|
|
704
614
|
continue
|
|
705
615
|
}
|
|
706
616
|
|
|
707
|
-
|
|
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
|
-
}
|
|
617
|
+
await config.mcp.remove(server.name)
|
|
714
618
|
}
|
|
715
619
|
}
|
|
716
620
|
|
|
@@ -719,245 +623,152 @@ async function uninstallHooks(config: ExtensionFacetConfig, owned: InstalledExte
|
|
|
719
623
|
return
|
|
720
624
|
}
|
|
721
625
|
|
|
722
|
-
|
|
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
|
-
}
|
|
626
|
+
await config.hooks.uninstall(owned.hooks)
|
|
770
627
|
}
|
|
771
628
|
|
|
772
|
-
function
|
|
629
|
+
export function createJsonMcpDriver(configFile: string): McpExtensionDriver {
|
|
773
630
|
return {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
trigger: hook.event
|
|
631
|
+
configFile,
|
|
632
|
+
async currentFingerprint(name: string) {
|
|
633
|
+
const mcpConfig = await readJsonFile(configFile)
|
|
634
|
+
const server = objectValue(mcpConfig.mcpServers)?.[name]
|
|
635
|
+
if (server === undefined) {
|
|
636
|
+
return undefined
|
|
781
637
|
}
|
|
782
|
-
]
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
638
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
639
|
+
return jsonFingerprint(server)
|
|
640
|
+
},
|
|
641
|
+
async install({ name, server }) {
|
|
642
|
+
const config = await readJsonFile(configFile)
|
|
643
|
+
const mcpServers = ensureObject(config, 'mcpServers')
|
|
644
|
+
const record = mcpServerRecord(server)
|
|
645
|
+
mcpServers[name] = record
|
|
646
|
+
await writeJsonFile(configFile, config)
|
|
647
|
+
return { fingerprint: jsonFingerprint(record), name, server: record }
|
|
648
|
+
},
|
|
649
|
+
async remove(name: string) {
|
|
650
|
+
const config = await readJsonFile(configFile)
|
|
651
|
+
const mcpServers = objectValue(config.mcpServers)
|
|
652
|
+
if (mcpServers) {
|
|
653
|
+
delete mcpServers[name]
|
|
792
654
|
}
|
|
793
|
-
await
|
|
655
|
+
await writeJsonFile(configFile, config)
|
|
794
656
|
}
|
|
795
657
|
}
|
|
796
658
|
}
|
|
797
659
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
)
|
|
803
|
-
|
|
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
|
-
}
|
|
660
|
+
export function createJsonHooksDriver(settingsFile: string, events: readonly string[]): HookExtensionDriver {
|
|
661
|
+
async function currentFingerprint(hook: InstalledHook): Promise<string | undefined> {
|
|
662
|
+
const settings = await readJsonFile(settingsFile)
|
|
663
|
+
const hooks = objectValue(settings.hooks)
|
|
664
|
+
const entries = arrayValue(hooks?.[hook.event]) ?? []
|
|
665
|
+
const exactMatches = entries.filter(entry => jsonFingerprint(entry) === hook.fingerprint)
|
|
830
666
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
667
|
+
if (exactMatches.length === 1) {
|
|
668
|
+
return hook.fingerprint
|
|
669
|
+
}
|
|
834
670
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
}
|
|
671
|
+
if (exactMatches.length > 1 || entries.some(entry => jsonHookCommands(entry).includes(hook.command))) {
|
|
672
|
+
return `${hook.fingerprint}:mismatch`
|
|
673
|
+
}
|
|
839
674
|
|
|
840
|
-
async function currentMcpFingerprint(config: ExtensionFacetConfig, name: string): Promise<string | undefined> {
|
|
841
|
-
if (!config.mcp) {
|
|
842
675
|
return undefined
|
|
843
676
|
}
|
|
844
677
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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)
|
|
678
|
+
return {
|
|
679
|
+
events,
|
|
680
|
+
async conflicts({ hooks }) {
|
|
681
|
+
const settings = await readJsonFile(settingsFile)
|
|
682
|
+
const issues: ExtensionIssue[] = []
|
|
683
|
+
|
|
684
|
+
for (const hook of hooks) {
|
|
685
|
+
if (jsonHookCommandExists(settings, hook)) {
|
|
686
|
+
issues.push({
|
|
687
|
+
kind: 'conflict',
|
|
688
|
+
reason: `Hook command already exists for ${hook.event} in ${settingsFile}.`,
|
|
689
|
+
resourceKind: 'hooks',
|
|
690
|
+
resourceName: hook.name
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
}
|
|
871
694
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
695
|
+
return issues
|
|
696
|
+
},
|
|
697
|
+
currentFingerprint,
|
|
698
|
+
async install({ hooks }) {
|
|
699
|
+
const settings = await readJsonFile(settingsFile)
|
|
700
|
+
const nativeHooks = ensureObject(settings, 'hooks')
|
|
701
|
+
const installed: InstalledHook[] = []
|
|
702
|
+
|
|
703
|
+
for (const hook of hooks) {
|
|
704
|
+
const eventHooks = ensureArray(nativeHooks, hook.event)
|
|
705
|
+
const entry = {
|
|
706
|
+
hooks: [{ command: hook.command, type: 'command' }]
|
|
707
|
+
}
|
|
708
|
+
eventHooks.push(entry)
|
|
709
|
+
installed.push({
|
|
710
|
+
command: hook.command,
|
|
711
|
+
event: hook.event,
|
|
712
|
+
fingerprint: jsonFingerprint(entry),
|
|
713
|
+
name: hook.name
|
|
714
|
+
})
|
|
715
|
+
}
|
|
877
716
|
|
|
878
|
-
|
|
879
|
-
|
|
717
|
+
await writeJsonFile(settingsFile, settings)
|
|
718
|
+
return installed
|
|
719
|
+
},
|
|
720
|
+
async restore(hooks: InstalledHook[]) {
|
|
721
|
+
const settings = await readJsonFile(settingsFile)
|
|
722
|
+
const nativeHooks = ensureObject(settings, 'hooks')
|
|
880
723
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
724
|
+
for (const hook of hooks) {
|
|
725
|
+
if (await currentFingerprint(hook)) {
|
|
726
|
+
continue
|
|
727
|
+
}
|
|
884
728
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
}
|
|
729
|
+
ensureArray(nativeHooks, hook.event).push({
|
|
730
|
+
hooks: [{ command: hook.command, type: 'command' }]
|
|
731
|
+
})
|
|
732
|
+
}
|
|
897
733
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
734
|
+
await writeJsonFile(settingsFile, settings)
|
|
735
|
+
},
|
|
736
|
+
async uninstall(hooks: InstalledHook[]) {
|
|
737
|
+
const settings = await readJsonFile(settingsFile)
|
|
738
|
+
const nativeHooks = objectValue(settings.hooks)
|
|
739
|
+
if (!nativeHooks) {
|
|
740
|
+
return
|
|
741
|
+
}
|
|
902
742
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
743
|
+
for (const hook of hooks) {
|
|
744
|
+
const entries = arrayValue(nativeHooks[hook.event])
|
|
745
|
+
if (!entries) {
|
|
746
|
+
continue
|
|
747
|
+
}
|
|
907
748
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
codexBeginMarker(name),
|
|
911
|
-
`[mcp_servers.${tomlKey(name)}]`,
|
|
912
|
-
`command = ${tomlString(server.command)}`,
|
|
913
|
-
`args = ${tomlArray(server.args ?? [])}`
|
|
914
|
-
]
|
|
749
|
+
nativeHooks[hook.event] = entries.filter(entry => jsonFingerprint(entry) !== hook.fingerprint)
|
|
750
|
+
}
|
|
915
751
|
|
|
916
|
-
|
|
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)}`)
|
|
752
|
+
await writeJsonFile(settingsFile, settings)
|
|
921
753
|
}
|
|
922
754
|
}
|
|
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
755
|
}
|
|
939
756
|
|
|
940
|
-
function
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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}`
|
|
757
|
+
export function mcpServerRecord(server: McpServerResource): McpServerResource {
|
|
758
|
+
return {
|
|
759
|
+
args: server.args ?? [],
|
|
760
|
+
command: server.command,
|
|
761
|
+
...(server.env ? { env: server.env } : {})
|
|
762
|
+
}
|
|
952
763
|
}
|
|
953
764
|
|
|
954
765
|
function jsonHookCommandExists(settings: JsonObject, hook: HookResource): boolean {
|
|
955
766
|
const hooks = objectValue(settings.hooks)
|
|
956
767
|
const eventHooks = arrayValue(hooks?.[hook.event])
|
|
957
|
-
return eventHooks?.some(entry =>
|
|
768
|
+
return eventHooks?.some(entry => jsonHookCommands(entry).includes(hook.command)) ?? false
|
|
958
769
|
}
|
|
959
770
|
|
|
960
|
-
function
|
|
771
|
+
function jsonHookCommands(entry: unknown): string[] {
|
|
961
772
|
const group = objectValue(entry)
|
|
962
773
|
const commands = arrayValue(group?.hooks) ?? []
|
|
963
774
|
return commands.flatMap(candidate => {
|
|
@@ -966,31 +777,6 @@ function claudeHookCommands(entry: unknown): string[] {
|
|
|
966
777
|
})
|
|
967
778
|
}
|
|
968
779
|
|
|
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
780
|
async function readState(config: ExtensionFacetConfig): Promise<ExtensionState> {
|
|
995
781
|
const state = await readJsonFile(join(config.configDirectory, STATE_FILE))
|
|
996
782
|
return { extensions: (objectValue(state.extensions) as Record<string, InstalledExtension> | undefined) ?? {} }
|
|
@@ -1008,17 +794,13 @@ function skillTargetPath(skillsDirectory: string, extensionId: string, skillPath
|
|
|
1008
794
|
return join(skillsDirectory, `${safeName(extensionId)}__${index}_${safeName(skillBaseName(skillPath))}`)
|
|
1009
795
|
}
|
|
1010
796
|
|
|
1011
|
-
function kiroHookTargetPath(hooksDirectory: string, extensionId: string, hook: HookResource): string {
|
|
1012
|
-
return join(hooksDirectory, `${safeName(extensionId)}__${safeName(hook.name)}.json`)
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
797
|
function skillBaseName(path: string): string {
|
|
1016
798
|
const base = basename(path)
|
|
1017
799
|
const extension = extname(base)
|
|
1018
800
|
return extension ? base.slice(0, -extension.length) : base
|
|
1019
801
|
}
|
|
1020
802
|
|
|
1021
|
-
function safeName(value: string): string {
|
|
803
|
+
export function safeName(value: string): string {
|
|
1022
804
|
return value.replaceAll(/[^A-Za-z0-9._-]/g, '_')
|
|
1023
805
|
}
|
|
1024
806
|
|
|
@@ -1026,7 +808,7 @@ function hookKey(hook: Pick<InstalledHook, 'command' | 'event'>): string {
|
|
|
1026
808
|
return `${hook.event}\u0000${hook.command}`
|
|
1027
809
|
}
|
|
1028
810
|
|
|
1029
|
-
async function pathExists(path: string): Promise<boolean> {
|
|
811
|
+
export async function pathExists(path: string): Promise<boolean> {
|
|
1030
812
|
try {
|
|
1031
813
|
await lstat(path)
|
|
1032
814
|
return true
|
|
@@ -1048,27 +830,11 @@ async function readJsonFile(path: string): Promise<JsonObject> {
|
|
|
1048
830
|
}
|
|
1049
831
|
}
|
|
1050
832
|
|
|
1051
|
-
async function writeJsonFile(path: string, value: unknown): Promise<void> {
|
|
833
|
+
export async function writeJsonFile(path: string, value: unknown): Promise<void> {
|
|
1052
834
|
await mkdir(dirname(path), { recursive: true })
|
|
1053
835
|
await writeFileAtomically(path, `${JSON.stringify(value, null, 2)}\n`)
|
|
1054
836
|
}
|
|
1055
837
|
|
|
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
838
|
async function writeFileAtomically(path: string, text: string): Promise<void> {
|
|
1073
839
|
const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`)
|
|
1074
840
|
await writeFile(temporaryPath, text)
|
|
@@ -1110,38 +876,6 @@ async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open
|
|
|
1110
876
|
throw new Error(`Timed out waiting for extension config lock: ${path}`)
|
|
1111
877
|
}
|
|
1112
878
|
|
|
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
879
|
function ensureObject(parent: JsonObject, key: string): JsonObject {
|
|
1146
880
|
const current = objectValue(parent[key])
|
|
1147
881
|
if (current) {
|
|
@@ -1172,19 +906,7 @@ function arrayValue(value: unknown): unknown[] | undefined {
|
|
|
1172
906
|
return Array.isArray(value) ? value : undefined
|
|
1173
907
|
}
|
|
1174
908
|
|
|
1175
|
-
function
|
|
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 {
|
|
909
|
+
export function jsonFingerprint(value: unknown): string {
|
|
1188
910
|
return textFingerprint(stableJson(value))
|
|
1189
911
|
}
|
|
1190
912
|
|
|
@@ -1192,7 +914,7 @@ function textFingerprint(value: string): string {
|
|
|
1192
914
|
return createHash('sha256').update(value).digest('hex')
|
|
1193
915
|
}
|
|
1194
916
|
|
|
1195
|
-
async function readJsonFileFingerprint(path: string): Promise<string | undefined> {
|
|
917
|
+
export async function readJsonFileFingerprint(path: string): Promise<string | undefined> {
|
|
1196
918
|
try {
|
|
1197
919
|
return jsonFingerprint(await readJsonFile(path))
|
|
1198
920
|
} catch (error) {
|
|
@@ -1218,10 +940,6 @@ function stableJson(value: unknown): string {
|
|
|
1218
940
|
return JSON.stringify(value)
|
|
1219
941
|
}
|
|
1220
942
|
|
|
1221
|
-
function escapeRegExp(value: string): string {
|
|
1222
|
-
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
943
|
function isAlreadyExists(error: unknown): boolean {
|
|
1226
944
|
return error instanceof Error && 'code' in error && error.code === 'EEXIST'
|
|
1227
945
|
}
|