@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.
@@ -21,35 +21,25 @@ type ExtensionFacetConfig = {
21
21
  context?: HarnessContext
22
22
  configDirectory: string
23
23
  skillsDirectory?: string
24
- mcp?: CodexMcpConfig | ClaudeMcpConfig | KiroMcpConfig
25
- hooks?: JsonHooksConfig | KiroHookFilesConfig
24
+ mcp?: McpExtensionDriver
25
+ hooks?: HookExtensionDriver
26
26
  }
27
27
 
28
- type CodexMcpConfig = {
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 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
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 (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))) {
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(config: ExtensionFacetConfig, owned: InstalledExtension): Promise<ExtensionIssue[]> {
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 currentMcpFingerprint(config, server.name)
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 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
- }
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 issues
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 currentHookFingerprint(config, hook)
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
- 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
- }
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
- if (config.hooks.kind === 'kiro-hook-files') {
541
- await installKiroHooks(config, extension, installed)
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
- 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)
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 currentMcpFingerprint(config, server.name))) {
567
+ if (!server.server || (await config.mcp.currentFingerprint(server.name))) {
625
568
  continue
626
569
  }
627
570
 
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
- }
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
- 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)
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 currentMcpFingerprint(config, server.name)
612
+ const current = await config.mcp.currentFingerprint(server.name)
703
613
  if (!current) {
704
614
  continue
705
615
  }
706
616
 
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
- }
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
- 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
- }
626
+ await config.hooks.uninstall(owned.hooks)
770
627
  }
771
628
 
772
- function kiroHookConfig(hook: HookResource): JsonObject {
629
+ export function createJsonMcpDriver(configFile: string): McpExtensionDriver {
773
630
  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
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
- 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
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 rm(hook.targetPath, { force: true })
655
+ await writeJsonFile(configFile, config)
794
656
  }
795
657
  }
796
658
  }
797
659
 
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
- }
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
- async function claudeMcpServerExists(configFile: string, name: string): Promise<boolean> {
832
- return jsonMcpServerExists(configFile, name)
833
- }
667
+ if (exactMatches.length === 1) {
668
+ return hook.fingerprint
669
+ }
834
670
 
835
- async function jsonMcpServerExists(configFile: string, name: string): Promise<boolean> {
836
- const config = await readJsonFile(configFile)
837
- return objectValue(config.mcpServers)?.[name] !== undefined
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
- 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)
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
- 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
- }
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
- return { fingerprint, name, server: mcpServerRecord(server) }
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
- async function removeKiroMcpServer(config: ExtensionFacetConfig, name: string): Promise<void> {
882
- await runKiroCommand(config, ['mcp', 'remove', '--scope', 'global', '--name', name])
883
- }
724
+ for (const hook of hooks) {
725
+ if (await currentFingerprint(hook)) {
726
+ continue
727
+ }
884
728
 
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
- }
729
+ ensureArray(nativeHooks, hook.event).push({
730
+ hooks: [{ command: hook.command, type: 'command' }]
731
+ })
732
+ }
897
733
 
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
- }
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
- async function codexMcpServerExists(configFile: string, name: string): Promise<boolean> {
904
- const config = await readTextFile(configFile)
905
- return codexMcpHeaderPattern(name).test(config)
906
- }
743
+ for (const hook of hooks) {
744
+ const entries = arrayValue(nativeHooks[hook.event])
745
+ if (!entries) {
746
+ continue
747
+ }
907
748
 
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
- ]
749
+ nativeHooks[hook.event] = entries.filter(entry => jsonFingerprint(entry) !== hook.fingerprint)
750
+ }
915
751
 
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)}`)
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 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}`
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 => claudeHookCommands(entry).includes(hook.command)) ?? false
768
+ return eventHooks?.some(entry => jsonHookCommands(entry).includes(hook.command)) ?? false
958
769
  }
959
770
 
960
- function claudeHookCommands(entry: unknown): string[] {
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 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 {
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
  }