@yancyyu/openhermit 1.6.29 → 1.6.30

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.
Files changed (152) hide show
  1. package/dist-renderer/assets/{ProjectEditorOverlay-CQm6jUR1.js → ProjectEditorOverlay-DsQt4FHy.js} +1 -1
  2. package/dist-renderer/assets/{TeamGraphOverlay-h0WDfifv.js → TeamGraphOverlay-BjZC53xf.js} +1 -1
  3. package/dist-renderer/assets/{_basePickBy-CgG_tjgX.js → _basePickBy-CrWocIjq.js} +1 -1
  4. package/dist-renderer/assets/{_baseUniq-DwPTU9lP.js → _baseUniq-B6d8ysWi.js} +1 -1
  5. package/dist-renderer/assets/{arc-7nIrGRzY.js → arc-DAIYCFP8.js} +1 -1
  6. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-BYhA6Ev2.js → architectureDiagram-VXUJARFQ-B3UudXJh.js} +1 -1
  7. package/dist-renderer/assets/{blockDiagram-VD42YOAC-BVpZUGDg.js → blockDiagram-VD42YOAC-DbptKQ4W.js} +1 -1
  8. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-DsdreMQ9.js → c4Diagram-YG6GDRKO-C4WQuZpV.js} +1 -1
  9. package/dist-renderer/assets/channel-DbjZvWii.js +1 -0
  10. package/dist-renderer/assets/{chunk-4BX2VUAB-CcoAs7Jd.js → chunk-4BX2VUAB-Dp7fVpI_.js} +1 -1
  11. package/dist-renderer/assets/{chunk-55IACEB6-CGGAOoXd.js → chunk-55IACEB6-B8KGfbAy.js} +1 -1
  12. package/dist-renderer/assets/{chunk-B4BG7PRW-FhpTEPvD.js → chunk-B4BG7PRW-BG1oJrjA.js} +1 -1
  13. package/dist-renderer/assets/{chunk-DI55MBZ5-DoYySbm1.js → chunk-DI55MBZ5-DRmxNjht.js} +1 -1
  14. package/dist-renderer/assets/{chunk-FMBD7UC4-e9l2tGHG.js → chunk-FMBD7UC4-D6VLvy16.js} +1 -1
  15. package/dist-renderer/assets/{chunk-QN33PNHL-DeiXVTCy.js → chunk-QN33PNHL-DZou1667.js} +1 -1
  16. package/dist-renderer/assets/{chunk-QZHKN3VN-DC2UJLJM.js → chunk-QZHKN3VN-CghmasSh.js} +1 -1
  17. package/dist-renderer/assets/{chunk-TZMSLE5B-BHFD9eZI.js → chunk-TZMSLE5B-B7apcMPK.js} +1 -1
  18. package/dist-renderer/assets/classDiagram-2ON5EDUG-D_FGxxsl.js +1 -0
  19. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-D_FGxxsl.js +1 -0
  20. package/dist-renderer/assets/clone-CJ1kxO2J.js +1 -0
  21. package/dist-renderer/assets/{cose-bilkent-S5V4N54A-BdybQraU.js → cose-bilkent-S5V4N54A-05e5uQDp.js} +1 -1
  22. package/dist-renderer/assets/{dagre-6UL2VRFP-DdF3pwM3.js → dagre-6UL2VRFP-B06bRykF.js} +1 -1
  23. package/dist-renderer/assets/{diagram-PSM6KHXK-B9Ldd3nh.js → diagram-PSM6KHXK-CY7VYQ7c.js} +1 -1
  24. package/dist-renderer/assets/{diagram-QEK2KX5R-XEqkrbpu.js → diagram-QEK2KX5R-BjKEH7dD.js} +1 -1
  25. package/dist-renderer/assets/{diagram-S2PKOQOG-CipwtY59.js → diagram-S2PKOQOG-Bf4ELS1_.js} +1 -1
  26. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-BB-2ISGo.js → erDiagram-Q2GNP2WA-DJ753_L9.js} +1 -1
  27. package/dist-renderer/assets/{flowDiagram-NV44I4VS-B8XmJ0u2.js → flowDiagram-NV44I4VS-B71S-lC-.js} +1 -1
  28. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-D-8XglBb.js → ganttDiagram-JELNMOA3-C_U42mSZ.js} +1 -1
  29. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-DL4ChakD.js → gitGraphDiagram-V2S2FVAM-DKUJU4Ns.js} +1 -1
  30. package/dist-renderer/assets/{graph-BiFNoBjP.js → graph-DY3qbzqj.js} +1 -1
  31. package/dist-renderer/assets/{index-BowUl0Jb.js → index-BlOrAXp3.js} +542 -532
  32. package/dist-renderer/assets/{index-6m1ZAymG.js → index-Bs27J5gB.js} +1 -1
  33. package/dist-renderer/assets/{index-Dp3kJTEe.js → index-C8B_nKOF.js} +1 -1
  34. package/dist-renderer/assets/index-CmZPUEhS.css +1 -0
  35. package/dist-renderer/assets/{index-TOpt_T7A.js → index-DLKyDr4T.js} +1 -1
  36. package/dist-renderer/assets/{index-qNBNjW4K.js → index-Dhsk3_DD.js} +1 -1
  37. package/dist-renderer/assets/{index-vAykq1H1.js → index-GpUvV2xs.js} +1 -1
  38. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DRIBfHDi.js → infoDiagram-HS3SLOUP-BNs0y3IG.js} +1 -1
  39. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-BOMiigU4.js → journeyDiagram-XKPGCS4Q-CqPnw4UV.js} +1 -1
  40. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-DDxeyjod.js → kanban-definition-3W4ZIXB7-SLlzcUJ2.js} +1 -1
  41. package/dist-renderer/assets/{layout-DNANbrI4.js → layout-BZLlNmbr.js} +1 -1
  42. package/dist-renderer/assets/{linear-DxEJi1yT.js → linear-qz6v45xy.js} +1 -1
  43. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-nBfGriW8.js → mindmap-definition-VGOIOE7T-B1-kmEWV.js} +1 -1
  44. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-Din5j6sV.js → pieDiagram-ADFJNKIX-B8a02iNx.js} +1 -1
  45. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-DMVK2BEQ.js → quadrantDiagram-AYHSOK5B-BKv1Xfou.js} +1 -1
  46. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-6SC94Gg_.js → requirementDiagram-UZGBJVZJ-B3DUpZi2.js} +1 -1
  47. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-CD2gghhu.js → sankeyDiagram-TZEHDZUN-DmPzuTsy.js} +1 -1
  48. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BnhkN7nZ.js → sequenceDiagram-WL72ISMW-Bo7RelRb.js} +1 -1
  49. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-Bn8XdYX-.js → stateDiagram-FKZM4ZOC-1epX98gV.js} +1 -1
  50. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-1b6sI1_g.js → stateDiagram-v2-4FDKWEC3-03Ym9PTr.js} +1 -1
  51. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-CNs3RPoa.js → timeline-definition-IT6M3QCI-r6isC62H.js} +1 -1
  52. package/dist-renderer/assets/treemap-GDKQZRPO-CGKpOUF2.js +162 -0
  53. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-B8o5J2f3.js → xychartDiagram-PRI3JC2R-t4-rwdAw.js} +1 -1
  54. package/dist-renderer/index.html +2 -2
  55. package/package.json +4 -1
  56. package/src/main/ipc/extensions.ts +353 -0
  57. package/src/main/server.ts +209 -6
  58. package/src/main/services/extensions/ExtensionFacadeService.ts +135 -0
  59. package/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts +190 -0
  60. package/src/main/services/extensions/catalog/McpCatalogAggregator.ts +150 -0
  61. package/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts +381 -0
  62. package/src/main/services/extensions/catalog/PluginCatalogService.ts +392 -0
  63. package/src/main/services/extensions/credentials/CredentialService.ts +343 -0
  64. package/src/main/services/extensions/install/McpInstallService.ts +407 -0
  65. package/src/main/services/extensions/install/PluginInstallService.ts +198 -0
  66. package/src/main/services/extensions/runtime/ClaudeCodeAdapter.ts +199 -0
  67. package/src/main/services/extensions/runtime/CodexAdapter.ts +100 -0
  68. package/src/main/services/extensions/runtime/CursorAdapter.ts +154 -0
  69. package/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +172 -0
  70. package/src/main/services/extensions/runtime/GeminiAdapter.ts +91 -0
  71. package/src/main/services/extensions/runtime/HarnessInstallAdapter.ts +49 -0
  72. package/src/main/services/extensions/runtime/McpConfigStateReader.ts +209 -0
  73. package/src/main/services/extensions/runtime/OpenCodeAdapter.ts +91 -0
  74. package/src/main/services/extensions/runtime/adapterRegistry.ts +54 -0
  75. package/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +214 -0
  76. package/src/main/services/extensions/runtime/mcpRuntimeJson.ts +45 -0
  77. package/src/main/services/extensions/skills/SkillImportService.ts +155 -0
  78. package/src/main/services/extensions/skills/SkillMetadataParser.ts +323 -0
  79. package/src/main/services/extensions/skills/SkillPlanService.ts +411 -0
  80. package/src/main/services/extensions/skills/SkillReviewService.ts +73 -0
  81. package/src/main/services/extensions/skills/SkillRootsResolver.ts +49 -0
  82. package/src/main/services/extensions/skills/SkillScaffoldService.ts +89 -0
  83. package/src/main/services/extensions/skills/SkillScanner.ts +117 -0
  84. package/src/main/services/extensions/skills/SkillValidator.ts +69 -0
  85. package/src/main/services/extensions/skills/SkillsCatalogService.ts +92 -0
  86. package/src/main/services/extensions/skills/SkillsMutationService.ts +146 -0
  87. package/src/main/services/extensions/skills/SkillsWatcherService.ts +134 -0
  88. package/src/main/services/extensions/state/McpInstallationStateService.ts +42 -0
  89. package/src/main/services/extensions/state/PluginInstallationStateService.ts +281 -0
  90. package/src/main/services/identity/AgentTeamsIdentityStore.ts +218 -0
  91. package/src/main/services/runtime/providerAwareCliEnv.ts +60 -0
  92. package/src/main/services/team/ClaudeBinaryResolver.ts +469 -0
  93. package/src/main/services/team/ClaudeDoctorProbe.ts +0 -0
  94. package/src/main/services/team/cliFlavor.ts +54 -0
  95. package/src/main/services/teams-mvp/TaskDispatchService.ts +3 -0
  96. package/src/main/utils/atomicWrite.ts +72 -0
  97. package/src/main/utils/childProcess.ts +554 -0
  98. package/src/main/utils/cliEnv.ts +54 -0
  99. package/src/main/utils/cliPathMerge.ts +97 -0
  100. package/src/main/utils/pathDecoder.ts +664 -0
  101. package/src/main/utils/pathValidation.ts +432 -0
  102. package/src/main/utils/shellEnv.ts +331 -0
  103. package/src/renderer/api/httpClient.ts +61 -0
  104. package/src/renderer/components/extensions/ExtensionStoreView.tsx +59 -34
  105. package/src/renderer/components/extensions/ExtensionsSubTabTrigger.tsx +1 -1
  106. package/src/renderer/components/extensions/common/ExtensionToast.tsx +141 -0
  107. package/src/renderer/components/extensions/common/HarnessSelector.tsx +71 -0
  108. package/src/renderer/components/extensions/env/EnvVarPanel.tsx +335 -0
  109. package/src/renderer/components/extensions/env/ProjectEnvPanel.tsx +239 -0
  110. package/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +14 -223
  111. package/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +11 -0
  112. package/src/renderer/components/extensions/mcp/McpServersPanel.tsx +51 -1
  113. package/src/renderer/components/extensions/skills/SkillsPanel.tsx +1 -126
  114. package/src/renderer/components/settings/sections/HarnessSection.tsx +2 -6
  115. package/src/renderer/components/settings/sections/TaskBusSection.tsx +17 -7
  116. package/src/renderer/components/sidebar/SidebarSessions.tsx +23 -0
  117. package/src/renderer/components/sidebar/WorkspaceBrowser.tsx +1 -7
  118. package/src/renderer/components/team/HarnessSelect.tsx +71 -0
  119. package/src/renderer/components/team/TeamDetailView.tsx +35 -0
  120. package/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +21 -12
  121. package/src/renderer/components/team/dialogs/EditTeamDialog.tsx +8 -13
  122. package/src/renderer/components/team/kanban/KanbanBoard.tsx +26 -64
  123. package/src/renderer/components/team/messages/MessagesPanel.tsx +28 -24
  124. package/src/renderer/components/terminal/TerminalPanel.tsx +156 -0
  125. package/src/renderer/hooks/useExtensionsTabState.ts +2 -2
  126. package/src/renderer/store/slices/extensionsSlice.ts +42 -107
  127. package/src/renderer/store/slices/teamSlice.ts +8 -2
  128. package/src/shared/types/api.ts +29 -0
  129. package/src/shared/types/extensions/index.ts +1 -0
  130. package/src/shared/types/extensions/mcp.ts +2 -0
  131. package/src/shared/types/extensions/plugin.ts +2 -1
  132. package/src/shared/types/extensions/skill.ts +7 -0
  133. package/src/shared/utils/providerExtensionCapabilities.ts +1 -1
  134. package/dist-renderer/assets/channel-C0SqeFU7.js +0 -1
  135. package/dist-renderer/assets/classDiagram-2ON5EDUG-DWew1HpM.js +0 -1
  136. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-DWew1HpM.js +0 -1
  137. package/dist-renderer/assets/clone-Dm-k63Yr.js +0 -1
  138. package/dist-renderer/assets/index-BhellmRb.css +0 -1
  139. package/dist-renderer/assets/treemap-GDKQZRPO-DU_yr827.js +0 -162
  140. package/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +0 -30
  141. package/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +0 -27
  142. package/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +0 -91
  143. package/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +0 -326
  144. package/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +0 -43
  145. package/src/features/recent-projects/main/index.ts +0 -3
  146. package/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts +0 -34
  147. package/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +0 -116
  148. package/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts +0 -20
  149. package/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts +0 -10
  150. package/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +0 -143
  151. package/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +0 -282
  152. package/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +0 -280
@@ -0,0 +1,117 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+
4
+ import { SkillMetadataParser, type SkillRelatedFiles } from './SkillMetadataParser';
5
+
6
+ import type { ResolvedSkillRoot } from './SkillRootsResolver';
7
+ import type { SkillCatalogItem, SkillDirectoryFlags } from '@shared/types/extensions';
8
+
9
+ const SKILL_FILE_CANDIDATES = ['SKILL.md', 'Skill.md', 'skill.md'] as const;
10
+
11
+ export class SkillScanner {
12
+ constructor(private readonly parser = new SkillMetadataParser()) {}
13
+
14
+ async scanRoot(root: ResolvedSkillRoot): Promise<SkillCatalogItem[]> {
15
+ try {
16
+ const rootStat = await fs.stat(root.rootPath);
17
+ if (!rootStat.isDirectory()) return [];
18
+ } catch {
19
+ return [];
20
+ }
21
+
22
+ const dirEntries = await fs.readdir(root.rootPath, { withFileTypes: true });
23
+ const skillDirs = dirEntries.filter((entry) => entry.isDirectory());
24
+
25
+ const skills = await Promise.all(
26
+ skillDirs.map(async (entry) => {
27
+ const skillDir = path.join(root.rootPath, entry.name);
28
+ const skillFile = await this.detectSkillFile(skillDir);
29
+ if (!skillFile) return null;
30
+
31
+ const [rawContent, stat, flags] = await Promise.all([
32
+ fs.readFile(skillFile, 'utf8'),
33
+ fs.stat(skillFile),
34
+ this.readFlags(skillDir),
35
+ ]);
36
+
37
+ return this.parser.parseCatalogItem({
38
+ skillDir,
39
+ folderName: entry.name,
40
+ skillFile,
41
+ rawContent,
42
+ modifiedAt: stat.mtimeMs,
43
+ flags,
44
+ root,
45
+ });
46
+ })
47
+ );
48
+
49
+ return skills.filter((entry): entry is SkillCatalogItem => entry !== null);
50
+ }
51
+
52
+ async detectSkillFile(skillDir: string): Promise<string | null> {
53
+ for (const candidate of SKILL_FILE_CANDIDATES) {
54
+ const filePath = path.join(skillDir, candidate);
55
+ try {
56
+ const stat = await fs.stat(filePath);
57
+ if (stat.isFile()) return filePath;
58
+ } catch {
59
+ // ignore
60
+ }
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ async readFlags(skillDir: string): Promise<SkillDirectoryFlags> {
67
+ const [hasScripts, hasReferences, hasAssets] = await Promise.all([
68
+ this.directoryExists(path.join(skillDir, 'scripts')),
69
+ this.directoryExists(path.join(skillDir, 'references')),
70
+ this.directoryExists(path.join(skillDir, 'assets')),
71
+ ]);
72
+
73
+ return { hasScripts, hasReferences, hasAssets };
74
+ }
75
+
76
+ async readRelatedFiles(skillDir: string): Promise<SkillRelatedFiles> {
77
+ const [referencesFiles, scriptFiles, assetFiles] = await Promise.all([
78
+ this.listRelativeFiles(path.join(skillDir, 'references')),
79
+ this.listRelativeFiles(path.join(skillDir, 'scripts')),
80
+ this.listRelativeFiles(path.join(skillDir, 'assets')),
81
+ ]);
82
+
83
+ return { referencesFiles, scriptFiles, assetFiles };
84
+ }
85
+
86
+ private async listRelativeFiles(targetDir: string, prefix = ''): Promise<string[]> {
87
+ try {
88
+ const stat = await fs.stat(targetDir);
89
+ if (!stat.isDirectory()) return [];
90
+ } catch {
91
+ return [];
92
+ }
93
+
94
+ const dirEntries = await fs.readdir(targetDir, { withFileTypes: true });
95
+ const files = await Promise.all(
96
+ dirEntries.map(async (entry) => {
97
+ const relativePath = prefix ? path.join(prefix, entry.name) : entry.name;
98
+ const fullPath = path.join(targetDir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ return this.listRelativeFiles(fullPath, relativePath);
101
+ }
102
+ return [relativePath];
103
+ })
104
+ );
105
+
106
+ return files.flat().sort((a, b) => a.localeCompare(b));
107
+ }
108
+
109
+ private async directoryExists(targetDir: string): Promise<boolean> {
110
+ try {
111
+ const stat = await fs.stat(targetDir);
112
+ return stat.isDirectory();
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,69 @@
1
+ import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots';
2
+
3
+ import type { SkillCatalogItem } from '@shared/types/extensions';
4
+
5
+ const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
6
+ hermit: 0,
7
+ claude: 1,
8
+ cursor: 2,
9
+ agents: 3,
10
+ codex: 4,
11
+ };
12
+
13
+ export class SkillValidator {
14
+ annotateCatalog(items: SkillCatalogItem[]): SkillCatalogItem[] {
15
+ const withDuplicates = this.annotateDuplicateNames(items);
16
+ return withDuplicates.sort((a, b) => {
17
+ if (a.isValid !== b.isValid) return a.isValid ? -1 : 1;
18
+ if (a.scope !== b.scope) return a.scope === 'project' ? -1 : 1;
19
+ if (a.rootKind !== b.rootKind)
20
+ return ROOT_PRECEDENCE[a.rootKind] - ROOT_PRECEDENCE[b.rootKind];
21
+ return a.name.localeCompare(b.name);
22
+ });
23
+ }
24
+
25
+ private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] {
26
+ const itemsByName = new Map<string, SkillCatalogItem[]>();
27
+ for (const item of items) {
28
+ const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
29
+ const bucket = itemsByName.get(key) ?? [];
30
+ bucket.push(item);
31
+ itemsByName.set(key, bucket);
32
+ }
33
+
34
+ return items.map((item) => {
35
+ const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
36
+ const duplicates = itemsByName.get(key) ?? [];
37
+ if (duplicates.length <= 1) {
38
+ return item;
39
+ }
40
+
41
+ if (item.issues.some((issue) => issue.code === 'duplicate-name')) {
42
+ return item;
43
+ }
44
+
45
+ const otherLocations = duplicates
46
+ .filter((candidate) => candidate.id !== item.id)
47
+ .map((candidate) => `${candidate.skillDir} (${this.formatRootLabel(candidate)})`)
48
+ .filter((value, index, values) => values.indexOf(value) === index)
49
+ .join('; ');
50
+
51
+ return {
52
+ ...item,
53
+ issues: [
54
+ ...item.issues,
55
+ {
56
+ code: 'duplicate-name',
57
+ message: `Another copy of "${item.name}" exists at: ${otherLocations}. Both entries are shown separately.`,
58
+ severity: 'warning',
59
+ },
60
+ ],
61
+ };
62
+ });
63
+ }
64
+
65
+ private formatRootLabel(item: SkillCatalogItem): string {
66
+ const rootLabel = formatSkillRootKind(item.rootKind);
67
+ return item.scope === 'project' ? `project ${rootLabel}` : rootLabel;
68
+ }
69
+ }
@@ -0,0 +1,92 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+
4
+ import { createLogger } from '@shared/utils/logger';
5
+
6
+ import { SkillMetadataParser } from './SkillMetadataParser';
7
+ import { type ResolvedSkillRoot, SkillRootsResolver } from './SkillRootsResolver';
8
+ import { SkillScanner } from './SkillScanner';
9
+ import { SkillValidator } from './SkillValidator';
10
+
11
+ import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
12
+
13
+ const logger = createLogger('Extensions:SkillsCatalog');
14
+
15
+ export class SkillsCatalogService {
16
+ constructor(
17
+ private readonly rootsResolver = new SkillRootsResolver(),
18
+ private readonly parser = new SkillMetadataParser(),
19
+ private readonly scanner = new SkillScanner(parser),
20
+ private readonly validator = new SkillValidator()
21
+ ) {}
22
+
23
+ async list(projectPath?: string): Promise<SkillCatalogItem[]> {
24
+ const roots = this.rootsResolver.resolve(projectPath);
25
+ const scannedItems = (
26
+ await Promise.all(roots.map((root) => this.readSkillsFromRoot(root)))
27
+ ).flat();
28
+ return this.validator.annotateCatalog(scannedItems);
29
+ }
30
+
31
+ async getDetail(skillId: string, projectPath?: string): Promise<SkillDetail | null> {
32
+ const roots = this.rootsResolver.resolve(projectPath);
33
+ const allowedRoots = new Set(roots.map((root) => path.resolve(root.rootPath)));
34
+ const normalizedSkillDir = path.resolve(skillId);
35
+
36
+ const owningRoot = roots.find((root) => this.isWithinRoot(normalizedSkillDir, root.rootPath));
37
+ if (!owningRoot || !allowedRoots.has(path.resolve(owningRoot.rootPath))) {
38
+ return null;
39
+ }
40
+
41
+ const folderName = path.basename(normalizedSkillDir);
42
+ const skillFile = await this.scanner.detectSkillFile(normalizedSkillDir);
43
+ if (!skillFile) return null;
44
+
45
+ try {
46
+ const [rawContent, stat, flags, relatedFiles] = await Promise.all([
47
+ fs.readFile(skillFile, 'utf8'),
48
+ fs.stat(skillFile),
49
+ this.scanner.readFlags(normalizedSkillDir),
50
+ this.scanner.readRelatedFiles(normalizedSkillDir),
51
+ ]);
52
+
53
+ const item = this.parser.parseCatalogItem({
54
+ skillDir: normalizedSkillDir,
55
+ folderName,
56
+ skillFile,
57
+ rawContent,
58
+ modifiedAt: stat.mtimeMs,
59
+ flags,
60
+ root: owningRoot,
61
+ });
62
+
63
+ return this.parser.parseDetail(item, rawContent, relatedFiles);
64
+ } catch (error) {
65
+ logger.warn(`Failed to read skill detail for ${skillId}`, error);
66
+ return null;
67
+ }
68
+ }
69
+
70
+ private async readSkillsFromRoot(root: ResolvedSkillRoot): Promise<SkillCatalogItem[]> {
71
+ try {
72
+ return await this.scanner.scanRoot(root);
73
+ } catch (error) {
74
+ logger.warn(`Failed to scan skills root ${root.rootPath}`, error);
75
+ return [];
76
+ }
77
+ }
78
+
79
+ private isWithinRoot(targetPath: string, rootPath: string): boolean {
80
+ const normalizedTarget = this.normalizeForContainment(targetPath);
81
+ const normalizedRoot = this.normalizeForContainment(rootPath);
82
+ const relativePath = path.relative(normalizedRoot, normalizedTarget);
83
+ return (
84
+ relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
85
+ );
86
+ }
87
+
88
+ private normalizeForContainment(value: string): string {
89
+ const resolved = path.resolve(path.normalize(value));
90
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
91
+ }
92
+ }
@@ -0,0 +1,146 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+
4
+ import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
5
+
6
+ import { SkillImportService } from './SkillImportService';
7
+ import { SkillPlanService } from './SkillPlanService';
8
+ import { SkillRootsResolver } from './SkillRootsResolver';
9
+ import { SkillScaffoldService } from './SkillScaffoldService';
10
+ import { SkillsCatalogService } from './SkillsCatalogService';
11
+
12
+ import type {
13
+ SkillDeleteRequest,
14
+ SkillDetail,
15
+ SkillImportRequest,
16
+ SkillReviewPreview,
17
+ SkillUpsertRequest,
18
+ } from '@shared/types/extensions';
19
+
20
+ export class SkillsMutationService {
21
+ constructor(
22
+ private readonly rootsResolver = new SkillRootsResolver(),
23
+ private readonly catalogService = new SkillsCatalogService(),
24
+ private readonly scaffoldService = new SkillScaffoldService(rootsResolver),
25
+ private readonly importService = new SkillImportService(),
26
+ private readonly planService = new SkillPlanService()
27
+ ) {}
28
+
29
+ async previewUpsert(request: SkillUpsertRequest): Promise<SkillReviewPreview> {
30
+ const targetSkillDir = await this.scaffoldService.resolveUpsertTarget(
31
+ request.scope,
32
+ request.rootKind,
33
+ request.projectPath,
34
+ request.folderName,
35
+ request.existingSkillId
36
+ );
37
+ const files = this.scaffoldService.normalizeDraftFiles(request.files);
38
+ const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
39
+ return plan.preview;
40
+ }
41
+
42
+ async applyUpsert(request: SkillUpsertRequest): Promise<SkillDetail | null> {
43
+ if (!request.reviewPlanId) {
44
+ throw new Error('Review the skill changes before saving.');
45
+ }
46
+
47
+ const targetSkillDir = await this.scaffoldService.resolveUpsertTarget(
48
+ request.scope,
49
+ request.rootKind,
50
+ request.projectPath,
51
+ request.folderName,
52
+ request.existingSkillId
53
+ );
54
+ const files = this.scaffoldService.normalizeDraftFiles(request.files);
55
+ const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
56
+ this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
57
+ await this.planService.applyPlan(plan);
58
+
59
+ return this.catalogService.getDetail(targetSkillDir, request.projectPath);
60
+ }
61
+
62
+ async previewImport(request: SkillImportRequest): Promise<SkillReviewPreview> {
63
+ const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
64
+ const inspection = await this.importService.inspectSourceDir(sourceDir);
65
+ const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
66
+ return {
67
+ ...plan.preview,
68
+ warnings: [...new Set([...inspection.warnings, ...plan.preview.warnings])],
69
+ };
70
+ }
71
+
72
+ async applyImport(request: SkillImportRequest): Promise<SkillDetail | null> {
73
+ if (!request.reviewPlanId) {
74
+ throw new Error('Review the import changes before saving.');
75
+ }
76
+
77
+ const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
78
+ const inspection = await this.importService.inspectSourceDir(sourceDir);
79
+ const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
80
+ this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
81
+ await this.planService.applyPlan(plan);
82
+
83
+ return this.catalogService.getDetail(targetSkillDir, request.projectPath);
84
+ }
85
+
86
+ async deleteSkill(request: SkillDeleteRequest): Promise<void> {
87
+ const skillDir = this.resolveExistingSkill(request.skillId, request.projectPath);
88
+ await fs.rm(skillDir, { recursive: true, force: true });
89
+ }
90
+
91
+ private async resolveImportTarget(
92
+ request: SkillImportRequest
93
+ ): Promise<{ sourceDir: string; targetSkillDir: string }> {
94
+ const sourceDir = await this.importService.validateSourceDir(request.sourceDir);
95
+
96
+ const root = this.resolveWritableRoot(request.scope, request.rootKind, request.projectPath);
97
+ await fs.mkdir(root.rootPath, { recursive: true });
98
+
99
+ const folderName = request.folderName?.trim() || path.basename(sourceDir);
100
+ const folderValidation = validateFileName(folderName);
101
+ if (!folderValidation.valid) {
102
+ throw new Error(folderValidation.error ?? 'Invalid folder name');
103
+ }
104
+
105
+ const targetSkillDir = path.join(root.rootPath, folderName);
106
+ if (!isPathWithinRoot(targetSkillDir, root.rootPath)) {
107
+ throw new Error('Import destination is outside the allowed root');
108
+ }
109
+
110
+ return { sourceDir, targetSkillDir };
111
+ }
112
+
113
+ private resolveWritableRoot(
114
+ scope: SkillUpsertRequest['scope'],
115
+ rootKind: SkillUpsertRequest['rootKind'],
116
+ projectPath?: string
117
+ ) {
118
+ const roots = this.rootsResolver.resolve(projectPath);
119
+ const match = roots.find((root) => root.scope === scope && root.rootKind === rootKind);
120
+ if (!match) {
121
+ throw new Error('Requested skill root is unavailable');
122
+ }
123
+ if (scope === 'project' && !projectPath) {
124
+ throw new Error('projectPath is required for project-scoped skills');
125
+ }
126
+ return match;
127
+ }
128
+
129
+ private resolveExistingSkill(skillId: string, projectPath?: string): string {
130
+ const normalizedSkillDir = path.resolve(skillId);
131
+ const roots = this.rootsResolver.resolve(projectPath);
132
+ const owningRoot = roots.find((root) => isPathWithinRoot(normalizedSkillDir, root.rootPath));
133
+ if (!owningRoot) {
134
+ throw new Error('Skill is outside the allowed roots');
135
+ }
136
+ return normalizedSkillDir;
137
+ }
138
+
139
+ private assertReviewedPlanMatches(reviewPlanId: string, currentPlanId: string): void {
140
+ if (reviewPlanId !== currentPlanId) {
141
+ throw new Error(
142
+ 'The skill files changed after review. Review the latest changes and try again.'
143
+ );
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,134 @@
1
+ import { isPathWithinRoot } from '@main/utils/pathValidation';
2
+ import { createLogger } from '@shared/utils/logger';
3
+ import { watch } from 'chokidar';
4
+
5
+ import { SkillRootsResolver } from './SkillRootsResolver';
6
+
7
+ import type { SkillWatcherEvent } from '@shared/types/extensions';
8
+ import type { FSWatcher } from 'chokidar';
9
+
10
+ const logger = createLogger('Extensions:SkillsWatcher');
11
+ const WATCHER_DEBOUNCE_MS = 250;
12
+
13
+ export class SkillsWatcherService {
14
+ private watcher: FSWatcher | null = null;
15
+ private subscriptions = new Map<string, string | null>();
16
+ private pendingEvents = new Map<string, SkillWatcherEvent>();
17
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
18
+ private emitChange: ((event: SkillWatcherEvent) => void) | null = null;
19
+ private nextWatchId = 0;
20
+
21
+ constructor(private readonly rootsResolver = new SkillRootsResolver()) {}
22
+
23
+ setEmitter(emitChange: (event: SkillWatcherEvent) => void): void {
24
+ this.emitChange = emitChange;
25
+ }
26
+
27
+ async start(projectPath?: string): Promise<string> {
28
+ const watchId = `skills-watch-${++this.nextWatchId}`;
29
+ this.subscriptions.set(watchId, projectPath ?? null);
30
+ await this.rebuildWatcher();
31
+ return watchId;
32
+ }
33
+
34
+ async stop(watchId: string): Promise<void> {
35
+ this.subscriptions.delete(watchId);
36
+ await this.rebuildWatcher();
37
+ }
38
+
39
+ private async rebuildWatcher(): Promise<void> {
40
+ if (this.flushTimer) {
41
+ clearTimeout(this.flushTimer);
42
+ this.flushTimer = null;
43
+ }
44
+ this.pendingEvents.clear();
45
+ if (this.watcher) {
46
+ await this.watcher.close();
47
+ this.watcher = null;
48
+ }
49
+
50
+ const roots = [
51
+ ...new Set(
52
+ [...this.subscriptions.values()].flatMap((projectPath) =>
53
+ this.rootsResolver.resolve(projectPath ?? undefined).map((root) => root.rootPath)
54
+ )
55
+ ),
56
+ ];
57
+
58
+ if (roots.length === 0) {
59
+ return;
60
+ }
61
+
62
+ this.watcher = watch(roots, {
63
+ ignoreInitial: true,
64
+ ignorePermissionErrors: true,
65
+ followSymlinks: false,
66
+ depth: 5,
67
+ awaitWriteFinish: {
68
+ stabilityThreshold: 200,
69
+ pollInterval: 100,
70
+ },
71
+ });
72
+
73
+ const queue = (type: SkillWatcherEvent['type'], filePath: string): void => {
74
+ this.enqueueEventsForPath(type, filePath);
75
+ if (this.flushTimer) return;
76
+ this.flushTimer = setTimeout(() => {
77
+ this.flushTimer = null;
78
+ if (this.emitChange) {
79
+ for (const event of this.pendingEvents.values()) {
80
+ this.emitChange(event);
81
+ }
82
+ }
83
+ this.pendingEvents.clear();
84
+ }, WATCHER_DEBOUNCE_MS);
85
+ };
86
+
87
+ this.watcher.on('add', (filePath) => queue('create', filePath));
88
+ this.watcher.on('addDir', (filePath) => queue('create', filePath));
89
+ this.watcher.on('change', (filePath) => queue('change', filePath));
90
+ this.watcher.on('unlink', (filePath) => queue('delete', filePath));
91
+ this.watcher.on('unlinkDir', (filePath) => queue('delete', filePath));
92
+ this.watcher.on('error', (error) => logger.warn('Skills watcher error', error));
93
+ }
94
+
95
+ async stopAll(): Promise<void> {
96
+ this.subscriptions.clear();
97
+ await this.rebuildWatcher();
98
+ }
99
+
100
+ private enqueueEventsForPath(type: SkillWatcherEvent['type'], filePath: string): void {
101
+ const matchedProjectPaths = new Set<string | null>();
102
+ let matchedUserRoot = false;
103
+
104
+ for (const projectPath of this.subscriptions.values()) {
105
+ const roots = this.rootsResolver.resolve(projectPath ?? undefined);
106
+ for (const root of roots) {
107
+ if (!isPathWithinRoot(filePath, root.rootPath)) continue;
108
+ if (root.scope === 'user') {
109
+ matchedUserRoot = true;
110
+ } else {
111
+ matchedProjectPaths.add(projectPath ?? null);
112
+ }
113
+ }
114
+ }
115
+
116
+ if (matchedUserRoot) {
117
+ this.pendingEvents.set(`user:${type}`, {
118
+ scope: 'user',
119
+ projectPath: null,
120
+ path: filePath,
121
+ type,
122
+ });
123
+ }
124
+
125
+ for (const projectPath of matchedProjectPaths) {
126
+ this.pendingEvents.set(`project:${projectPath ?? 'null'}:${type}`, {
127
+ scope: 'project',
128
+ projectPath,
129
+ path: filePath,
130
+ type,
131
+ });
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Resolves installed MCP server state through the active runtime adapter.
3
+ *
4
+ * Direct Claude mode reads CLI-managed config files.
5
+ * Multimodel mode uses the structured `mcp list --json` runtime contract.
6
+ */
7
+
8
+ import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
9
+
10
+ import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
11
+ import type { InstalledMcpEntry } from '@shared/types/extensions';
12
+
13
+ const CACHE_TTL_MS = 10_000; // 10 seconds
14
+
15
+ interface TimedCache<T> {
16
+ data: T;
17
+ fetchedAt: number;
18
+ }
19
+
20
+ export class McpInstallationStateService {
21
+ private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
22
+
23
+ constructor(
24
+ private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
25
+ ) {}
26
+
27
+ async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
28
+ const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`;
29
+ const cached = this.cache.get(cacheKey);
30
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
31
+ return cached.data;
32
+ }
33
+
34
+ const entries = await this.runtimeAdapter.getInstalledMcp(projectPath);
35
+ this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
36
+ return entries;
37
+ }
38
+
39
+ invalidateCache(): void {
40
+ this.cache.clear();
41
+ }
42
+ }