From f81427db7db0bb6e113ab43aeddf05931daf6de2 Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Tue, 17 Mar 2026 08:27:44 +0100 Subject: [PATCH 1/8] feat(Skills): added action to create skills Signed-off-by: Evzen Gasta --- .../src/plugin/skill/skill-manager.spec.ts | 6 ++++++ .../main/src/plugin/skill/skill-manager.ts | 1 + .../src/lib/skills/SkillTargetCards.svelte | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 packages/renderer/src/lib/skills/SkillTargetCards.svelte diff --git a/packages/main/src/plugin/skill/skill-manager.spec.ts b/packages/main/src/plugin/skill/skill-manager.spec.ts index ffbe30e94..616c43a7e 100644 --- a/packages/main/src/plugin/skill/skill-manager.spec.ts +++ b/packages/main/src/plugin/skill/skill-manager.spec.ts @@ -34,6 +34,10 @@ const SKILLS_DIR = resolve('/test/skills'); vi.mock('node:fs'); vi.mock('node:fs/promises'); +vi.mock('node:os', async () => ({ + ...(await vi.importActual('node:os')), + homedir: (): string => '/home/test', +})); const updateMock = vi.fn().mockResolvedValue(undefined); const getMock = vi.fn(); @@ -52,6 +56,8 @@ const apiSender: ApiSenderType = { receive: vi.fn(), }; +const CLAUDE_SKILLS_DIR = resolve('/home/test/.claude/skills'); + const directories = { getSkillsDirectory: vi.fn().mockReturnValue(SKILLS_DIR), } as unknown as Directories; diff --git a/packages/main/src/plugin/skill/skill-manager.ts b/packages/main/src/plugin/skill/skill-manager.ts index 5f532a75a..858d7d7a8 100644 --- a/packages/main/src/plugin/skill/skill-manager.ts +++ b/packages/main/src/plugin/skill/skill-manager.ts @@ -39,6 +39,7 @@ import { type SkillFolderInfo, type SkillInfo, type SkillMetadata, + type SkillTarget, } from '/@api/skill/skill-info.js'; const RESERVED_WORDS = ['anthropic', 'claude']; diff --git a/packages/renderer/src/lib/skills/SkillTargetCards.svelte b/packages/renderer/src/lib/skills/SkillTargetCards.svelte new file mode 100644 index 000000000..8e5b2a9f7 --- /dev/null +++ b/packages/renderer/src/lib/skills/SkillTargetCards.svelte @@ -0,0 +1,20 @@ + + + From 021f14832fced3d9946b415765b215f03abbbf6a Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Wed, 18 Mar 2026 09:56:55 +0100 Subject: [PATCH 2/8] chore: added dynamic storage registered by extension Signed-off-by: Evzen Gasta --- .../src/plugin/skill/skill-manager.spec.ts | 6 ------ .../main/src/plugin/skill/skill-manager.ts | 1 - .../src/lib/skills/SkillTargetCards.svelte | 20 ------------------- 3 files changed, 27 deletions(-) delete mode 100644 packages/renderer/src/lib/skills/SkillTargetCards.svelte diff --git a/packages/main/src/plugin/skill/skill-manager.spec.ts b/packages/main/src/plugin/skill/skill-manager.spec.ts index 616c43a7e..ffbe30e94 100644 --- a/packages/main/src/plugin/skill/skill-manager.spec.ts +++ b/packages/main/src/plugin/skill/skill-manager.spec.ts @@ -34,10 +34,6 @@ const SKILLS_DIR = resolve('/test/skills'); vi.mock('node:fs'); vi.mock('node:fs/promises'); -vi.mock('node:os', async () => ({ - ...(await vi.importActual('node:os')), - homedir: (): string => '/home/test', -})); const updateMock = vi.fn().mockResolvedValue(undefined); const getMock = vi.fn(); @@ -56,8 +52,6 @@ const apiSender: ApiSenderType = { receive: vi.fn(), }; -const CLAUDE_SKILLS_DIR = resolve('/home/test/.claude/skills'); - const directories = { getSkillsDirectory: vi.fn().mockReturnValue(SKILLS_DIR), } as unknown as Directories; diff --git a/packages/main/src/plugin/skill/skill-manager.ts b/packages/main/src/plugin/skill/skill-manager.ts index 858d7d7a8..5f532a75a 100644 --- a/packages/main/src/plugin/skill/skill-manager.ts +++ b/packages/main/src/plugin/skill/skill-manager.ts @@ -39,7 +39,6 @@ import { type SkillFolderInfo, type SkillInfo, type SkillMetadata, - type SkillTarget, } from '/@api/skill/skill-info.js'; const RESERVED_WORDS = ['anthropic', 'claude']; diff --git a/packages/renderer/src/lib/skills/SkillTargetCards.svelte b/packages/renderer/src/lib/skills/SkillTargetCards.svelte deleted file mode 100644 index 8e5b2a9f7..000000000 --- a/packages/renderer/src/lib/skills/SkillTargetCards.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - From 9b017a2432ab57abfadcab9c9b60795f4a0e40a1 Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Tue, 10 Mar 2026 07:52:29 +0100 Subject: [PATCH 3/8] chore: applied suggestions Signed-off-by: Evzen Gasta --- packages/main/src/plugin/skill/skill-manager.spec.ts | 7 ++++--- packages/main/src/plugin/skill/skill-manager.ts | 2 +- packages/preload/src/index.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/main/src/plugin/skill/skill-manager.spec.ts b/packages/main/src/plugin/skill/skill-manager.spec.ts index ffbe30e94..d54836736 100644 --- a/packages/main/src/plugin/skill/skill-manager.spec.ts +++ b/packages/main/src/plugin/skill/skill-manager.spec.ts @@ -34,6 +34,7 @@ const SKILLS_DIR = resolve('/test/skills'); vi.mock('node:fs'); vi.mock('node:fs/promises'); +vi.mock('node:os', () => ({ homedir: (): string => '/test-home' })); const updateMock = vi.fn().mockResolvedValue(undefined); const getMock = vi.fn(); @@ -337,7 +338,7 @@ test('registerSkill should throw when SKILL.md not found', async () => { const skillManager = createSkillManager(); - await expect(skillManager.registerSkill(resolve('/missing/folder'))).rejects.toThrow('SKILL.md not found'); + await expect(skillManager.registerSkill('/missing/folder')).rejects.toThrow('SKILL.md not found'); }); test('registerSkill should throw on duplicate name', async () => { @@ -345,9 +346,9 @@ test('registerSkill should throw on duplicate name', async () => { vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); - await skillManager.registerSkill(resolve('/first/folder')); + await skillManager.registerSkill('/first/folder'); - await expect(skillManager.registerSkill(resolve('/second/folder'))).rejects.toThrow( + await expect(skillManager.registerSkill('/second/folder')).rejects.toThrow( `Skill with name 'my-test-skill' already registered`, ); }); diff --git a/packages/main/src/plugin/skill/skill-manager.ts b/packages/main/src/plugin/skill/skill-manager.ts index 5f532a75a..d4af8bfe2 100644 --- a/packages/main/src/plugin/skill/skill-manager.ts +++ b/packages/main/src/plugin/skill/skill-manager.ts @@ -217,7 +217,7 @@ export class SkillManager { * and the reference is persisted to config. */ async registerSkill(folderPath: string): Promise { - const resolvedPath = resolve(folderPath); + const resolvedPath = resolve(homedir(), folderPath); const skillFilePath = join(resolvedPath, SKILL_FILE_NAME); if (!existsSync(skillFilePath)) { diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index cd744b21f..b8e50bc55 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -137,7 +137,7 @@ import type { ChunkProviderInfo } from '/@api/rag/chunk-provider-info'; import type { RagEnvironment } from '/@api/rag/rag-environment'; import type { ExtensionBanner, RecommendedRegistry } from '/@api/recommendations/recommendations'; import type { ReleaseNotesInfo } from '/@api/release-notes-info'; -import type { SkillFileContent, SkillFolderInfo, SkillInfo } from '/@api/skill/skill-info'; +import type { SkillFileContent, SkillFolderInfo, SkillInfo, SkillMetadata } from '/@api/skill/skill-info'; import type { StatusBarEntryDescriptor } from '/@api/status-bar'; import type { PinOption } from '/@api/status-bar/pin-option'; import type { TelemetryMessages } from '/@api/telemetry'; @@ -361,7 +361,7 @@ export function initExposure(): void { return ipcInvoke('flows:listSchedules'); }); - contextBridge.exposeInMainWorld('listSkills', async (): Promise> => { + contextBridge.exposeInMainWorld('listSkills', async (): Promise> => { return ipcInvoke('skill-manager:listSkills'); }); From 6abab64558eaf47991b3b87db1dcd3e7db2c78a9 Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Fri, 6 Mar 2026 21:42:06 +0100 Subject: [PATCH 4/8] feat(Skills list): added page to display skills Signed-off-by: Evzen Gasta --- packages/renderer/src/lib/skills/SkillsList.svelte | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/renderer/src/lib/skills/SkillsList.svelte b/packages/renderer/src/lib/skills/SkillsList.svelte index 29774ec6b..fc9d8aade 100644 --- a/packages/renderer/src/lib/skills/SkillsList.svelte +++ b/packages/renderer/src/lib/skills/SkillsList.svelte @@ -60,11 +60,7 @@ function closeCreateDialog(): void { {#snippet additionalActions()} {/snippet} - - {#snippet content()} -
{#if skills.length === 0} {#if searchTerm} From fea1f1007a5c71616cf1a2be59b21a225d865007 Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Tue, 10 Mar 2026 20:27:12 +0100 Subject: [PATCH 5/8] chore: rebase Signed-off-by: Evzen Gasta --- packages/main/src/plugin/skill/skill-manager.spec.ts | 7 +++---- packages/main/src/plugin/skill/skill-manager.ts | 2 +- packages/preload/src/index.ts | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/main/src/plugin/skill/skill-manager.spec.ts b/packages/main/src/plugin/skill/skill-manager.spec.ts index d54836736..ffbe30e94 100644 --- a/packages/main/src/plugin/skill/skill-manager.spec.ts +++ b/packages/main/src/plugin/skill/skill-manager.spec.ts @@ -34,7 +34,6 @@ const SKILLS_DIR = resolve('/test/skills'); vi.mock('node:fs'); vi.mock('node:fs/promises'); -vi.mock('node:os', () => ({ homedir: (): string => '/test-home' })); const updateMock = vi.fn().mockResolvedValue(undefined); const getMock = vi.fn(); @@ -338,7 +337,7 @@ test('registerSkill should throw when SKILL.md not found', async () => { const skillManager = createSkillManager(); - await expect(skillManager.registerSkill('/missing/folder')).rejects.toThrow('SKILL.md not found'); + await expect(skillManager.registerSkill(resolve('/missing/folder'))).rejects.toThrow('SKILL.md not found'); }); test('registerSkill should throw on duplicate name', async () => { @@ -346,9 +345,9 @@ test('registerSkill should throw on duplicate name', async () => { vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); - await skillManager.registerSkill('/first/folder'); + await skillManager.registerSkill(resolve('/first/folder')); - await expect(skillManager.registerSkill('/second/folder')).rejects.toThrow( + await expect(skillManager.registerSkill(resolve('/second/folder'))).rejects.toThrow( `Skill with name 'my-test-skill' already registered`, ); }); diff --git a/packages/main/src/plugin/skill/skill-manager.ts b/packages/main/src/plugin/skill/skill-manager.ts index d4af8bfe2..5f532a75a 100644 --- a/packages/main/src/plugin/skill/skill-manager.ts +++ b/packages/main/src/plugin/skill/skill-manager.ts @@ -217,7 +217,7 @@ export class SkillManager { * and the reference is persisted to config. */ async registerSkill(folderPath: string): Promise { - const resolvedPath = resolve(homedir(), folderPath); + const resolvedPath = resolve(folderPath); const skillFilePath = join(resolvedPath, SKILL_FILE_NAME); if (!existsSync(skillFilePath)) { diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index b8e50bc55..cd744b21f 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -137,7 +137,7 @@ import type { ChunkProviderInfo } from '/@api/rag/chunk-provider-info'; import type { RagEnvironment } from '/@api/rag/rag-environment'; import type { ExtensionBanner, RecommendedRegistry } from '/@api/recommendations/recommendations'; import type { ReleaseNotesInfo } from '/@api/release-notes-info'; -import type { SkillFileContent, SkillFolderInfo, SkillInfo, SkillMetadata } from '/@api/skill/skill-info'; +import type { SkillFileContent, SkillFolderInfo, SkillInfo } from '/@api/skill/skill-info'; import type { StatusBarEntryDescriptor } from '/@api/status-bar'; import type { PinOption } from '/@api/status-bar/pin-option'; import type { TelemetryMessages } from '/@api/telemetry'; @@ -361,7 +361,7 @@ export function initExposure(): void { return ipcInvoke('flows:listSchedules'); }); - contextBridge.exposeInMainWorld('listSkills', async (): Promise> => { + contextBridge.exposeInMainWorld('listSkills', async (): Promise> => { return ipcInvoke('skill-manager:listSkills'); }); From 28d0847dc094411ebf31fec6a3e6236c2463525d Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Wed, 11 Mar 2026 08:19:08 +0100 Subject: [PATCH 6/8] feat(Claude extension): added Claude extension Signed-off-by: Evzen Gasta --- extensions/claude/icon.png | Bin 0 -> 9337 bytes extensions/claude/package.json | 27 +++++++ extensions/claude/scripts/build.js | 46 +++++++++++ extensions/claude/src/claude-extension.ts | 47 +++++++++++ extensions/claude/src/extension.spec.ts | 76 ++++++++++++++++++ extensions/claude/src/extension.ts | 34 ++++++++ extensions/claude/tsconfig.json | 12 +++ extensions/claude/vite.config.js | 67 +++++++++++++++ packages/extension-api/src/extension-api.d.ts | 24 ++++++ .../plugin/extension/extension-loader.spec.ts | 7 ++ .../src/plugin/extension/extension-loader.ts | 20 +++++ .../src/plugin/skill/skill-manager.spec.ts | 32 ++++++-- .../main/src/plugin/skill/skill-manager.ts | 12 ++- .../renderer/src/lib/skills/SkillsList.svelte | 4 + pnpm-lock.yaml | 18 +++++ 15 files changed, 417 insertions(+), 9 deletions(-) create mode 100644 extensions/claude/icon.png create mode 100644 extensions/claude/package.json create mode 100644 extensions/claude/scripts/build.js create mode 100644 extensions/claude/src/claude-extension.ts create mode 100644 extensions/claude/src/extension.spec.ts create mode 100644 extensions/claude/src/extension.ts create mode 100644 extensions/claude/tsconfig.json create mode 100644 extensions/claude/vite.config.js diff --git a/extensions/claude/icon.png b/extensions/claude/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e29be47a1fe665d82baa6abf711197b1b17ba56a GIT binary patch literal 9337 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 { + const unzip = new AdmZip(destFile); + unzip.extractAllTo(unzippedDirectory); +}); diff --git a/extensions/claude/src/claude-extension.ts b/extensions/claude/src/claude-extension.ts new file mode 100644 index 000000000..a7f14835c --- /dev/null +++ b/extensions/claude/src/claude-extension.ts @@ -0,0 +1,47 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { Disposable, ExtensionContext } from '@kortex-app/api'; +import * as extensionApi from '@kortex-app/api'; + +export class ClaudeExtension implements Disposable { + constructor(private readonly extensionContext: ExtensionContext) {} + + static getClaudeSkillsDir(): string { + return join(homedir(), '.claude', 'skills'); + } + + async init(): Promise { + const claudeSkillsDir = ClaudeExtension.getClaudeSkillsDir(); + + const targetDisposable = extensionApi.skills.registerSkillFolder({ + label: 'Claude Skills', + badge: 'Claude', + icon: './icon.png', + baseDirectory: claudeSkillsDir, + }); + this.extensionContext.subscriptions.push(targetDisposable); + } + + dispose(): void { + // Subscriptions are disposed by the extension loader on deactivation + } +} diff --git a/extensions/claude/src/extension.spec.ts b/extensions/claude/src/extension.spec.ts new file mode 100644 index 000000000..055195a36 --- /dev/null +++ b/extensions/claude/src/extension.spec.ts @@ -0,0 +1,76 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { Disposable, ExtensionContext } from '@kortex-app/api'; +import * as extensionApi from '@kortex-app/api'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ClaudeExtension } from './claude-extension'; + +vi.mock(import('node:os')); +vi.mock(import('@kortex-app/api')); + +const MOCK_HOME = '/home/testuser'; +const CLAUDE_SKILLS_DIR = join(MOCK_HOME, '.claude', 'skills'); + +function createMockExtensionContext(): ExtensionContext { + return { + subscriptions: [], + } as unknown as ExtensionContext; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(homedir).mockReturnValue(MOCK_HOME); +}); + +describe('ClaudeExtension', () => { + test('getClaudeSkillsDir returns expected path', () => { + expect(ClaudeExtension.getClaudeSkillsDir()).toBe(CLAUDE_SKILLS_DIR); + }); + + test('registers skill folder with correct parameters', async () => { + const mockDisposable: Disposable = { dispose: vi.fn() }; + vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); + + const context = createMockExtensionContext(); + const ext = new ClaudeExtension(context); + await ext.init(); + + expect(extensionApi.skills.registerSkillFolder).toHaveBeenCalledWith({ + label: 'Claude Skills', + badge: 'Claude', + icon: './icon.png', + baseDirectory: CLAUDE_SKILLS_DIR, + }); + }); + + test('pushes disposable to extension context subscriptions', async () => { + const mockDisposable: Disposable = { dispose: vi.fn() }; + vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); + + const context = createMockExtensionContext(); + const ext = new ClaudeExtension(context); + await ext.init(); + + expect(context.subscriptions).toContain(mockDisposable); + }); +}); diff --git a/extensions/claude/src/extension.ts b/extensions/claude/src/extension.ts new file mode 100644 index 000000000..0d0260e78 --- /dev/null +++ b/extensions/claude/src/extension.ts @@ -0,0 +1,34 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ExtensionContext } from '@kortex-app/api'; + +import { ClaudeExtension } from './claude-extension'; + +export async function activate(extensionContext: ExtensionContext): Promise { + console.log('starting claude extension'); + + const claude = new ClaudeExtension(extensionContext); + extensionContext.subscriptions.push(claude); + + await claude.init(); +} + +export function deactivate(): void { + console.log('stopping claude extension'); +} diff --git a/extensions/claude/tsconfig.json b/extensions/claude/tsconfig.json new file mode 100644 index 000000000..c6b5be16b --- /dev/null +++ b/extensions/claude/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["ES2024"], + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/extensions/claude/vite.config.js b/extensions/claude/vite.config.js new file mode 100644 index 000000000..55f50ac57 --- /dev/null +++ b/extensions/claude/vite.config.js @@ -0,0 +1,67 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { join } from 'path'; +import { builtinModules } from 'module'; + +const PACKAGE_ROOT = __dirname; + +/** + * @type {import('vite').UserConfig} + * @see https://vitejs.dev/config/ + */ +const config = { + mode: process.env.MODE, + root: PACKAGE_ROOT, + envDir: process.cwd(), + resolve: { + alias: { + '/@/': join(PACKAGE_ROOT, 'src') + '/', + }, + }, + build: { + sourcemap: 'inline', + target: 'esnext', + outDir: 'dist', + assetsDir: '.', + minify: process.env.MODE === 'production' ? 'esbuild' : false, + lib: { + entry: 'src/extension.ts', + formats: ['cjs'], + }, + rollupOptions: { + external: ['@kortex-app/api', ...builtinModules.flatMap(p => [p, `node:${p}`])], + output: { + entryFileNames: '[name].js', + }, + }, + emptyOutDir: true, + reportCompressedSize: false, + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], + alias: { + '@kortex-app/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@kortex-app/api.js'), + }, + }, +}; + +export default config; diff --git a/packages/extension-api/src/extension-api.d.ts b/packages/extension-api/src/extension-api.d.ts index 516b860ad..6ef98478b 100644 --- a/packages/extension-api/src/extension-api.d.ts +++ b/packages/extension-api/src/extension-api.d.ts @@ -5510,4 +5510,28 @@ declare module '@kortex-app/api' { export namespace rag { export function registerChunkProvider(provider: ChunkProvider): Disposable; } + + export interface SkillFolderRegistration { + label: string; + badge: string; + icon?: string; + baseDirectory: string; + } + + export namespace skills { + /** + * Registers a skill folder that provides a storage location for skills. + * Extensions use this to contribute their own skill directories (e.g. Claude, Cursor). + * The folder is automatically unregistered when the extension is deactivated. + * @param folder The folder registration containing label, badge, and base directory + */ + export function registerSkillFolder(folder: SkillFolderRegistration): Disposable; + + /** + * Registers a skill from an external folder containing a SKILL.md file. + * The folder is not copied; only a reference is stored. + * @param folderPath Absolute path to the folder containing SKILL.md + */ + export function registerSkill(folderPath: string): Promise; + } } diff --git a/packages/main/src/plugin/extension/extension-loader.spec.ts b/packages/main/src/plugin/extension/extension-loader.spec.ts index d00524574..74caf5b0e 100644 --- a/packages/main/src/plugin/extension/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension/extension-loader.spec.ts @@ -33,6 +33,7 @@ import type { FeatureRegistry } from '/@/plugin/feature-registry.js'; import type { KubeGeneratorRegistry } from '/@/plugin/kubernetes/kube-generator-registry.js'; import type { MCPRegistry } from '/@/plugin/mcp/mcp-registry.js'; import { NavigationManager } from '/@/plugin/navigation/navigation-manager.js'; +import type { SkillManager } from '/@/plugin/skill/skill-manager.js'; import type { WebviewRegistry } from '/@/plugin/webview/webview-registry.js'; import type { ApiSenderType } from '/@api/api-sender/api-sender-type.js'; import type { ContributionInfo } from '/@api/contribution-info.js'; @@ -165,6 +166,11 @@ const featureRegistry: FeatureRegistry = { const mcpRegistry: MCPRegistry = {} as unknown as MCPRegistry; +const skillManager: SkillManager = { + registerSkill: vi.fn(), + unregisterSkill: vi.fn(), +} as unknown as SkillManager; + const apiSender: ApiSenderType = { send: vi.fn() } as unknown as ApiSenderType; const trayMenuRegistry: TrayMenuRegistry = {} as unknown as TrayMenuRegistry; @@ -396,6 +402,7 @@ beforeEach(() => { featureRegistry, mcpRegistry, chunkProviderRegistry, + skillManager, ); }); diff --git a/packages/main/src/plugin/extension/extension-loader.ts b/packages/main/src/plugin/extension/extension-loader.ts index a34997cca..82b71108a 100644 --- a/packages/main/src/plugin/extension/extension-loader.ts +++ b/packages/main/src/plugin/extension/extension-loader.ts @@ -81,6 +81,7 @@ import { ProviderRegistry } from '../provider-registry.js'; import { Proxy } from '../proxy.js'; import { createHttpPatchedModules } from '../proxy-resolver.js'; import { SafeStorageRegistry } from '../safe-storage/safe-storage-registry.js'; +import { SkillManager } from '../skill/skill-manager.js'; import { StatusBarAlignLeft, StatusBarAlignRight, @@ -232,6 +233,8 @@ export class ExtensionLoader implements IAsyncDisposable { private mcpRegistry: MCPRegistry, @inject(ChunkProviderRegistry) private chunkProviderRegistry: ChunkProviderRegistry, + @inject(SkillManager) + private skillManager: SkillManager, ) { this.pluginsDirectory = directories.getPluginsDirectory(); this.pluginsScanDirectory = directories.getPluginsScanDirectory(); @@ -1697,6 +1700,22 @@ export class ExtensionLoader implements IAsyncDisposable { }, }; + const skills: typeof containerDesktopAPI.skills = { + registerSkillFolder: (folder: containerDesktopAPI.SkillFolderRegistration): containerDesktopAPI.Disposable => { + let resolvedIcon: string | undefined; + if (folder.icon) { + const img = instance.updateImage(folder.icon, extensionPath); + resolvedIcon = typeof img === 'string' ? img : img?.light; + } + const disposable = this.skillManager.registerSkillFolder({ ...folder, icon: resolvedIcon }); + disposables.push(disposable); + return disposable; + }, + registerSkill: async (folderPath: string): Promise => { + await this.skillManager.registerSkill(folderPath); + }, + }; + return { // Types Disposable: Disposable, @@ -1734,6 +1753,7 @@ export class ExtensionLoader implements IAsyncDisposable { net, mcpRegistry, rag, + skills, }; } diff --git a/packages/main/src/plugin/skill/skill-manager.spec.ts b/packages/main/src/plugin/skill/skill-manager.spec.ts index ffbe30e94..127155e46 100644 --- a/packages/main/src/plugin/skill/skill-manager.spec.ts +++ b/packages/main/src/plugin/skill/skill-manager.spec.ts @@ -315,14 +315,32 @@ test('parseSkillFile should throw when description contains XML tags', async () ); }); -test('registerSkill should reference the original folder without copying', async () => { +test('registerSkill should return a disposable that unregisters the skill', async () => { vi.mocked(existsSync).mockImplementation(p => String(p).endsWith(SKILL_FILE_NAME)); vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); await skillManager.init(); const externalPath = resolve('/my/skill/folder'); - const skill = await skillManager.registerSkill(externalPath); + const disposable = await skillManager.registerSkill(externalPath); + + expect(disposable).toBeDefined(); + expect(disposable.dispose).toBeDefined(); + expect(skillManager.listSkills()).toHaveLength(1); + + disposable.dispose(); + + expect(skillManager.listSkills()).toHaveLength(0); +}); + +test('addSkill should reference the original folder without copying', async () => { + vi.mocked(existsSync).mockImplementation(p => String(p).endsWith(SKILL_FILE_NAME)); + vi.mocked(readFile).mockResolvedValue(validSkillMd); + + const skillManager = createSkillManager(); + await skillManager.init(); + const externalPath = resolve('/my/skill/folder'); + const skill = await skillManager.addSkill(externalPath); expect(skill.name).toBe('my-test-skill'); expect(skill.description).toBe('A test skill for unit testing'); @@ -358,7 +376,7 @@ test('disableSkill should disable a registered skill', async () => { const skillManager = createSkillManager(); await skillManager.init(); - const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(apiSender.send).mockClear(); updateMock.mockClear(); @@ -376,7 +394,7 @@ test('enableSkill should re-enable a disabled skill', async () => { const skillManager = createSkillManager(); await skillManager.init(); - const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); skillManager.disableSkill(skill.name); expect(skillManager.listSkills().at(0)?.enabled).toBe(false); @@ -409,7 +427,7 @@ test('unregisterSkill should delete folder for managed skills', async () => { const skillManager = createSkillManager(); await skillManager.init(); - const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(apiSender.send).mockClear(); updateMock.mockClear(); @@ -468,7 +486,7 @@ test('getSkillContent should return the SKILL.md content for a registered skill' vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); - const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(readFile).mockResolvedValue('full markdown content'); @@ -488,7 +506,7 @@ test('listSkillFolderContent should return folder entries for a registered skill vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); - const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(readdir).mockResolvedValue(['SKILL.md', 'utils.ts', 'templates'] as unknown as Awaited< ReturnType diff --git a/packages/main/src/plugin/skill/skill-manager.ts b/packages/main/src/plugin/skill/skill-manager.ts index 5f532a75a..f03fb8321 100644 --- a/packages/main/src/plugin/skill/skill-manager.ts +++ b/packages/main/src/plugin/skill/skill-manager.ts @@ -26,6 +26,7 @@ import { dump, load } from 'js-yaml'; import { IPCHandle } from '/@/plugin/api.js'; import { Directories } from '/@/plugin/directories.js'; +import { Disposable } from '/@/plugin/types/disposable.js'; import { ApiSenderType } from '/@api/api-sender/api-sender-type.js'; import type { IConfigurationNode } from '/@api/configuration/models.js'; import { IConfigurationRegistry } from '/@api/configuration/models.js'; @@ -102,7 +103,7 @@ export class SkillManager { }); this.ipcHandle('skill-manager:registerSkill', async (_listener, folderPath: string): Promise => { - return this.registerSkill(folderPath); + return this.addSkill(folderPath); }); this.ipcHandle('skill-manager:disableSkill', async (_listener, name: string): Promise => { @@ -216,7 +217,14 @@ export class SkillManager { * path. The original folder is not copied. The skill is enabled immediately * and the reference is persisted to config. */ - async registerSkill(folderPath: string): Promise { + async registerSkill(folderPath: string): Promise { + const skill = await this.addSkill(folderPath); + return Disposable.create(() => { + this.unregisterSkill(skill.name).catch(console.error); + }); + } + + async addSkill(folderPath: string): Promise { const resolvedPath = resolve(folderPath); const skillFilePath = join(resolvedPath, SKILL_FILE_NAME); diff --git a/packages/renderer/src/lib/skills/SkillsList.svelte b/packages/renderer/src/lib/skills/SkillsList.svelte index fc9d8aade..29774ec6b 100644 --- a/packages/renderer/src/lib/skills/SkillsList.svelte +++ b/packages/renderer/src/lib/skills/SkillsList.svelte @@ -60,7 +60,11 @@ function closeCreateDialog(): void { {#snippet additionalActions()} {/snippet} + + {#snippet content()} +
{#if skills.length === 0} {#if searchTerm} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bd730407..64add0e1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,24 @@ importers: specifier: ^0.2.1 version: 0.2.1 + extensions/claude: + devDependencies: + '@kortex-app/api': + specifier: workspace:* + version: link:../../packages/extension-api + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 + mkdirp: + specifier: ^3.0.1 + version: 3.0.1 + vite: + specifier: ^7.1.2 + version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.36.0)(tsx@4.20.3)(yaml@2.8.2) + vitest: + specifier: ^4.0.10 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(terser@5.36.0)(tsx@4.20.3)(yaml@2.8.2) + extensions/container/packages/api: dependencies: '@kortex-app/api': From fcf93f3412f97a4454c845155e96a0f16b51b71b Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Thu, 2 Apr 2026 13:40:56 +0200 Subject: [PATCH 7/8] refactor: migrated to inversify pattern Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Evzen Gasta --- extensions/claude/package.json | 3 + .../claude/src/claude-extension.spec.ts | 62 +++++++++++++ extensions/claude/src/claude-extension.ts | 50 ++++++---- extensions/claude/src/extension.spec.ts | 60 ++++-------- extensions/claude/src/extension.ts | 15 ++- .../src/inject/inversify-binding.spec.ts | 61 +++++++++++++ .../claude/src/inject/inversify-binding.ts | 51 +++++++++++ extensions/claude/src/inject/symbol.ts | 19 ++++ .../claude/src/manager/_manager-module.ts | 27 ++++++ .../src/manager/claude-skills-manager.spec.ts | 91 +++++++++++++++++++ .../src/manager/claude-skills-manager.ts | 55 +++++++++++ extensions/claude/tsconfig.json | 15 ++- extensions/claude/vite.config.js | 1 + package.json | 3 +- pnpm-lock.yaml | 4 + 15 files changed, 443 insertions(+), 74 deletions(-) create mode 100644 extensions/claude/src/claude-extension.spec.ts create mode 100644 extensions/claude/src/inject/inversify-binding.spec.ts create mode 100644 extensions/claude/src/inject/inversify-binding.ts create mode 100644 extensions/claude/src/inject/symbol.ts create mode 100644 extensions/claude/src/manager/_manager-module.ts create mode 100644 extensions/claude/src/manager/claude-skills-manager.spec.ts create mode 100644 extensions/claude/src/manager/claude-skills-manager.ts diff --git a/extensions/claude/package.json b/extensions/claude/package.json index 52808f319..4b53122ad 100644 --- a/extensions/claude/package.json +++ b/extensions/claude/package.json @@ -17,6 +17,9 @@ "test:watch": "vitest watch --coverage", "watch": "vite build --watch" }, + "dependencies": { + "inversify": "^7.7.1" + }, "devDependencies": { "@kortex-app/api": "workspace:*", "adm-zip": "^0.5.16", diff --git a/extensions/claude/src/claude-extension.spec.ts b/extensions/claude/src/claude-extension.spec.ts new file mode 100644 index 000000000..c5783ce50 --- /dev/null +++ b/extensions/claude/src/claude-extension.spec.ts @@ -0,0 +1,62 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ExtensionContext } from '@kortex-app/api'; +import type { Container } from 'inversify'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ClaudeExtension } from '/@/claude-extension'; +import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager'; + +vi.mock(import('/@/manager/claude-skills-manager')); + +class TestClaudeExtension extends ClaudeExtension { + getContainer(): Container | undefined { + return super.getContainer(); + } +} + +describe('ClaudeExtension', () => { + let extensionContext: ExtensionContext; + let claudeExtension: TestClaudeExtension; + + beforeEach(() => { + vi.resetAllMocks(); + extensionContext = { subscriptions: [] } as unknown as ExtensionContext; + claudeExtension = new TestClaudeExtension(extensionContext); + }); + + test('activate', async () => { + await claudeExtension.activate(); + expect(ClaudeSkillsManager.prototype.init).toHaveBeenCalled(); + }); + + test('activate handles error during container creation', async () => { + const faultyGetAsync = vi.fn().mockRejectedValue(new Error('Container creation failed')); + vi.spyOn(claudeExtension, 'getContainer').mockReturnValue({ + getAsync: faultyGetAsync, + } as unknown as Container); + await expect(claudeExtension.activate()).rejects.toThrow('Container creation failed'); + }); + + test('deactivate disposes subscriptions', async () => { + await claudeExtension.activate(); + await claudeExtension.deactivate(); + expect(ClaudeSkillsManager.prototype.dispose).toHaveBeenCalled(); + }); +}); diff --git a/extensions/claude/src/claude-extension.ts b/extensions/claude/src/claude-extension.ts index a7f14835c..bf9e532d4 100644 --- a/extensions/claude/src/claude-extension.ts +++ b/extensions/claude/src/claude-extension.ts @@ -16,32 +16,44 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import type { ExtensionContext } from '@kortex-app/api'; +import type { Container } from 'inversify'; -import type { Disposable, ExtensionContext } from '@kortex-app/api'; -import * as extensionApi from '@kortex-app/api'; +import { InversifyBinding } from '/@/inject/inversify-binding'; +import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager'; -export class ClaudeExtension implements Disposable { - constructor(private readonly extensionContext: ExtensionContext) {} +export class ClaudeExtension { + #extensionContext: ExtensionContext; - static getClaudeSkillsDir(): string { - return join(homedir(), '.claude', 'skills'); + #inversifyBinding: InversifyBinding | undefined; + #container: Container | undefined; + #claudeSkillsManager: ClaudeSkillsManager | undefined; + + constructor(extensionContext: ExtensionContext) { + this.#extensionContext = extensionContext; } - async init(): Promise { - const claudeSkillsDir = ClaudeExtension.getClaudeSkillsDir(); + async activate(): Promise { + this.#inversifyBinding = new InversifyBinding(this.#extensionContext); + this.#container = await this.#inversifyBinding.initBindings(); + + try { + this.#claudeSkillsManager = await this.getContainer()?.getAsync(ClaudeSkillsManager); + } catch (e) { + console.error('Error while creating the Claude skills manager', e); + throw e; + } + + await this.#claudeSkillsManager?.init(); + } - const targetDisposable = extensionApi.skills.registerSkillFolder({ - label: 'Claude Skills', - badge: 'Claude', - icon: './icon.png', - baseDirectory: claudeSkillsDir, - }); - this.extensionContext.subscriptions.push(targetDisposable); + protected getContainer(): Container | undefined { + return this.#container; } - dispose(): void { - // Subscriptions are disposed by the extension loader on deactivation + async deactivate(): Promise { + await this.#inversifyBinding?.dispose(); + this.#claudeSkillsManager?.dispose(); + this.#claudeSkillsManager = undefined; } } diff --git a/extensions/claude/src/extension.spec.ts b/extensions/claude/src/extension.spec.ts index 055195a36..67acc209a 100644 --- a/extensions/claude/src/extension.spec.ts +++ b/extensions/claude/src/extension.spec.ts @@ -16,61 +16,33 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import type { Disposable, ExtensionContext } from '@kortex-app/api'; -import * as extensionApi from '@kortex-app/api'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ExtensionContext } from '@kortex-app/api'; +import { beforeEach, expect, test, vi } from 'vitest'; import { ClaudeExtension } from './claude-extension'; +import { activate, deactivate } from './extension'; -vi.mock(import('node:os')); -vi.mock(import('@kortex-app/api')); - -const MOCK_HOME = '/home/testuser'; -const CLAUDE_SKILLS_DIR = join(MOCK_HOME, '.claude', 'skills'); +let extensionContextMock: ExtensionContext; -function createMockExtensionContext(): ExtensionContext { - return { - subscriptions: [], - } as unknown as ExtensionContext; -} +vi.mock(import('./claude-extension')); beforeEach(() => { + vi.restoreAllMocks(); vi.resetAllMocks(); - vi.mocked(homedir).mockReturnValue(MOCK_HOME); -}); -describe('ClaudeExtension', () => { - test('getClaudeSkillsDir returns expected path', () => { - expect(ClaudeExtension.getClaudeSkillsDir()).toBe(CLAUDE_SKILLS_DIR); - }); - - test('registers skill folder with correct parameters', async () => { - const mockDisposable: Disposable = { dispose: vi.fn() }; - vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); + extensionContextMock = {} as ExtensionContext; +}); - const context = createMockExtensionContext(); - const ext = new ClaudeExtension(context); - await ext.init(); +test('should initialize and activate the ClaudeExtension when activate is called', async () => { + await activate(extensionContextMock); - expect(extensionApi.skills.registerSkillFolder).toHaveBeenCalledWith({ - label: 'Claude Skills', - badge: 'Claude', - icon: './icon.png', - baseDirectory: CLAUDE_SKILLS_DIR, - }); - }); + expect(ClaudeExtension.prototype.activate).toHaveBeenCalled(); +}); - test('pushes disposable to extension context subscriptions', async () => { - const mockDisposable: Disposable = { dispose: vi.fn() }; - vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); +test('should call deactivate when deactivate is called', async () => { + await activate(extensionContextMock); - const context = createMockExtensionContext(); - const ext = new ClaudeExtension(context); - await ext.init(); + await deactivate(); - expect(context.subscriptions).toContain(mockDisposable); - }); + expect(ClaudeExtension.prototype.deactivate).toHaveBeenCalled(); }); diff --git a/extensions/claude/src/extension.ts b/extensions/claude/src/extension.ts index 0d0260e78..33b260282 100644 --- a/extensions/claude/src/extension.ts +++ b/extensions/claude/src/extension.ts @@ -20,15 +20,14 @@ import type { ExtensionContext } from '@kortex-app/api'; import { ClaudeExtension } from './claude-extension'; -export async function activate(extensionContext: ExtensionContext): Promise { - console.log('starting claude extension'); - - const claude = new ClaudeExtension(extensionContext); - extensionContext.subscriptions.push(claude); +let claudeExtension: ClaudeExtension | undefined; - await claude.init(); +export async function activate(extensionContext: ExtensionContext): Promise { + claudeExtension ??= new ClaudeExtension(extensionContext); + await claudeExtension.activate(); } -export function deactivate(): void { - console.log('stopping claude extension'); +export async function deactivate(): Promise { + await claudeExtension?.deactivate(); + claudeExtension = undefined; } diff --git a/extensions/claude/src/inject/inversify-binding.spec.ts b/extensions/claude/src/inject/inversify-binding.spec.ts new file mode 100644 index 000000000..733e4dd37 --- /dev/null +++ b/extensions/claude/src/inject/inversify-binding.spec.ts @@ -0,0 +1,61 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ExtensionContext } from '@kortex-app/api'; +import { Container } from 'inversify'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { managersModule } from '/@/manager/_manager-module'; +import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager'; + +import { InversifyBinding } from './inversify-binding'; +import { ExtensionContextSymbol } from './symbol'; + +let inversifyBinding: InversifyBinding; + +const extensionContextMock = {} as ExtensionContext; + +vi.mock(import('inversify')); + +describe('InversifyBinding', () => { + beforeEach(() => { + vi.resetAllMocks(); + inversifyBinding = new InversifyBinding(extensionContextMock); + vi.mocked(Container.prototype.bind).mockReturnValue({ + toConstantValue: vi.fn(), + } as unknown as ReturnType); + }); + + test('should initialize bindings correctly', async () => { + const container = await inversifyBinding.initBindings(); + + await container.getAsync(ClaudeSkillsManager); + + expect(vi.mocked(Container.prototype.bind)).toHaveBeenCalledWith(ExtensionContextSymbol); + + expect(vi.mocked(Container.prototype.load)).toHaveBeenCalledWith(managersModule); + }); + + test('should dispose of the container', async () => { + const container = await inversifyBinding.initBindings(); + + await inversifyBinding.dispose(); + + expect(container.unbindAll).toHaveBeenCalled(); + }); +}); diff --git a/extensions/claude/src/inject/inversify-binding.ts b/extensions/claude/src/inject/inversify-binding.ts new file mode 100644 index 000000000..ed8e7d967 --- /dev/null +++ b/extensions/claude/src/inject/inversify-binding.ts @@ -0,0 +1,51 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ExtensionContext } from '@kortex-app/api'; +import { Container } from 'inversify'; + +import { ExtensionContextSymbol } from '/@/inject/symbol'; +import { managersModule } from '/@/manager/_manager-module'; +import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager'; + +export class InversifyBinding { + #container: Container | undefined; + + readonly #extensionContext: ExtensionContext; + + constructor(extensionContext: ExtensionContext) { + this.#extensionContext = extensionContext; + } + + public async initBindings(): Promise { + this.#container = new Container(); + + this.#container.bind(ExtensionContextSymbol).toConstantValue(this.#extensionContext); + + await this.#container.load(managersModule); + + await this.#container.getAsync(ClaudeSkillsManager); + return this.#container; + } + + async dispose(): Promise { + if (this.#container) { + await this.#container.unbindAll(); + } + } +} diff --git a/extensions/claude/src/inject/symbol.ts b/extensions/claude/src/inject/symbol.ts new file mode 100644 index 000000000..642b6c307 --- /dev/null +++ b/extensions/claude/src/inject/symbol.ts @@ -0,0 +1,19 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export const ExtensionContextSymbol = Symbol.for('ExtensionContext'); diff --git a/extensions/claude/src/manager/_manager-module.ts b/extensions/claude/src/manager/_manager-module.ts new file mode 100644 index 000000000..fd1797762 --- /dev/null +++ b/extensions/claude/src/manager/_manager-module.ts @@ -0,0 +1,27 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { ContainerModule } from 'inversify'; + +import { ClaudeSkillsManager } from './claude-skills-manager'; + +const managersModule = new ContainerModule(options => { + options.bind(ClaudeSkillsManager).toSelf().inSingletonScope(); +}); + +export { managersModule }; diff --git a/extensions/claude/src/manager/claude-skills-manager.spec.ts b/extensions/claude/src/manager/claude-skills-manager.spec.ts new file mode 100644 index 000000000..7477f5117 --- /dev/null +++ b/extensions/claude/src/manager/claude-skills-manager.spec.ts @@ -0,0 +1,91 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { Disposable, ExtensionContext } from '@kortex-app/api'; +import * as extensionApi from '@kortex-app/api'; +import { Container } from 'inversify'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ExtensionContextSymbol } from '/@/inject/symbol'; + +import { ClaudeSkillsManager } from './claude-skills-manager'; + +vi.mock(import('node:os')); +vi.mock(import('@kortex-app/api')); + +const MOCK_HOME = '/home/testuser'; +const CLAUDE_SKILLS_DIR = join(MOCK_HOME, '.claude', 'skills'); + +describe('ClaudeSkillsManager', () => { + let claudeSkillsManager: ClaudeSkillsManager; + let extensionContextMock: ExtensionContext; + + beforeEach(async () => { + vi.resetAllMocks(); + vi.mocked(homedir).mockReturnValue(MOCK_HOME); + + extensionContextMock = { + subscriptions: [], + } as unknown as ExtensionContext; + + const container = new Container(); + container.bind(ClaudeSkillsManager).toSelf(); + container.bind(ExtensionContextSymbol).toConstantValue(extensionContextMock); + claudeSkillsManager = await container.getAsync(ClaudeSkillsManager); + }); + + test('getClaudeSkillsDir returns expected path', () => { + expect(ClaudeSkillsManager.getClaudeSkillsDir()).toBe(CLAUDE_SKILLS_DIR); + }); + + test('registers skill folder with correct parameters', async () => { + const mockDisposable: Disposable = { dispose: vi.fn() }; + vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); + + await claudeSkillsManager.init(); + + expect(extensionApi.skills.registerSkillFolder).toHaveBeenCalledWith({ + label: 'Claude Skills', + badge: 'Claude', + icon: './icon.png', + baseDirectory: CLAUDE_SKILLS_DIR, + }); + }); + + test('pushes disposable to extension context subscriptions', async () => { + const mockDisposable: Disposable = { dispose: vi.fn() }; + vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); + + await claudeSkillsManager.init(); + + expect(extensionContextMock.subscriptions).toContain(mockDisposable); + }); + + test('dispose cleans up skill folder registration', async () => { + const mockDisposable: Disposable = { dispose: vi.fn() }; + vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); + + await claudeSkillsManager.init(); + claudeSkillsManager.dispose(); + + expect(mockDisposable.dispose).toHaveBeenCalled(); + }); +}); diff --git a/extensions/claude/src/manager/claude-skills-manager.ts b/extensions/claude/src/manager/claude-skills-manager.ts new file mode 100644 index 000000000..349613eb9 --- /dev/null +++ b/extensions/claude/src/manager/claude-skills-manager.ts @@ -0,0 +1,55 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { Disposable, ExtensionContext } from '@kortex-app/api'; +import { skills } from '@kortex-app/api'; +import { inject, injectable } from 'inversify'; + +import { ExtensionContextSymbol } from '/@/inject/symbol'; + +@injectable() +export class ClaudeSkillsManager { + @inject(ExtensionContextSymbol) + private extensionContext: ExtensionContext; + + #skillFolderDisposable: Disposable | undefined; + + static getClaudeSkillsDir(): string { + return join(homedir(), '.claude', 'skills'); + } + + async init(): Promise { + const claudeSkillsDir = ClaudeSkillsManager.getClaudeSkillsDir(); + + this.#skillFolderDisposable = skills.registerSkillFolder({ + label: 'Claude Skills', + badge: 'Claude', + icon: './icon.png', + baseDirectory: claudeSkillsDir, + }); + this.extensionContext.subscriptions.push(this.#skillFolderDisposable); + } + + dispose(): void { + this.#skillFolderDisposable?.dispose(); + this.#skillFolderDisposable = undefined; + } +} diff --git a/extensions/claude/tsconfig.json b/extensions/claude/tsconfig.json index c6b5be16b..b0b8fa910 100644 --- a/extensions/claude/tsconfig.json +++ b/extensions/claude/tsconfig.json @@ -1,12 +1,23 @@ { "compilerOptions": { "strict": true, + "module": "esnext", "lib": ["ES2024"], "sourceMap": true, "rootDir": "src", "outDir": "dist", + "target": "esnext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "resolveJsonModule": true, "skipLibCheck": true, - "types": ["node"] + "types": ["node"], + "paths": { + "/@/*": ["./src/*"] + } }, - "include": ["src"] + "include": ["src", "types/*.d.ts"] } diff --git a/extensions/claude/vite.config.js b/extensions/claude/vite.config.js index 55f50ac57..9cd4a5e54 100644 --- a/extensions/claude/vite.config.js +++ b/extensions/claude/vite.config.js @@ -33,6 +33,7 @@ const config = { alias: { '/@/': join(PACKAGE_ROOT, 'src') + '/', }, + mainFields: ['module', 'jsnext:main', 'jsnext'], }, build: { sourcemap: 'inline', diff --git a/package.json b/package.json index ba94aee0d..7f608acc4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "scripts": { "build": "pnpm run build:main && pnpm run build:preload && pnpm run build:preload-webview && npm run build:preload:types && pnpm run build:renderer && pnpm run build:extensions", "build:main": "cd ./packages/main && vite build", - "build:extensions": "pnpm run build:extensions:container && pnpm run build:extensions:docling && pnpm run build:extensions:gemini && pnpm run build:extensions:goose && pnpm run build:extensions:mcp-registries && pnpm run build:extensions:milvus && pnpm run build:extensions:ollama && pnpm run build:extensions:openai-compatible && pnpm run build:extensions:openshift-ai && pnpm run build:extensions:ramalama", + "build:extensions": "pnpm run build:extensions:claude && pnpm run build:extensions:container && pnpm run build:extensions:docling && pnpm run build:extensions:gemini && pnpm run build:extensions:goose && pnpm run build:extensions:mcp-registries && pnpm run build:extensions:milvus && pnpm run build:extensions:ollama && pnpm run build:extensions:openai-compatible && pnpm run build:extensions:openshift-ai && pnpm run build:extensions:ramalama", + "build:extensions:claude": "cd ./extensions/claude && pnpm run build", "build:extensions:container": "cd ./extensions/container/packages/extension && pnpm run build", "build:extensions:docling": "cd ./extensions/docling && pnpm run build", "build:extensions:gemini": "cd ./extensions/gemini && pnpm run build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64add0e1c..e6ad9131b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,10 @@ importers: version: 0.2.1 extensions/claude: + dependencies: + inversify: + specifier: ^7.7.1 + version: 7.11.0(reflect-metadata@0.2.2) devDependencies: '@kortex-app/api': specifier: workspace:* From 517470f3bbe19b4b866017b922491893eced30bc Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Thu, 2 Apr 2026 14:17:13 +0200 Subject: [PATCH 8/8] chore: removed unnecessary logic Signed-off-by: Evzen Gasta --- .../claude/src/claude-extension.spec.ts | 1 + extensions/claude/src/claude-extension.ts | 16 ++++++- .../src/inject/inversify-binding.spec.ts | 8 ++-- .../claude/src/inject/inversify-binding.ts | 9 ++-- extensions/claude/src/inject/symbol.ts | 1 + .../src/manager/claude-skills-manager.spec.ts | 48 ++++++------------- .../src/manager/claude-skills-manager.ts | 24 ++++------ packages/extension-api/src/extension-api.d.ts | 24 ---------- .../plugin/extension/extension-loader.spec.ts | 7 --- .../src/plugin/extension/extension-loader.ts | 20 -------- .../src/plugin/skill/skill-manager.spec.ts | 32 +++---------- .../main/src/plugin/skill/skill-manager.ts | 12 +---- 12 files changed, 62 insertions(+), 140 deletions(-) diff --git a/extensions/claude/src/claude-extension.spec.ts b/extensions/claude/src/claude-extension.spec.ts index c5783ce50..3dec490ab 100644 --- a/extensions/claude/src/claude-extension.spec.ts +++ b/extensions/claude/src/claude-extension.spec.ts @@ -23,6 +23,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { ClaudeExtension } from '/@/claude-extension'; import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager'; +vi.mock(import('@kortex-app/api')); vi.mock(import('/@/manager/claude-skills-manager')); class TestClaudeExtension extends ClaudeExtension { diff --git a/extensions/claude/src/claude-extension.ts b/extensions/claude/src/claude-extension.ts index bf9e532d4..3bfef5d3c 100644 --- a/extensions/claude/src/claude-extension.ts +++ b/extensions/claude/src/claude-extension.ts @@ -17,6 +17,7 @@ ***********************************************************************/ import type { ExtensionContext } from '@kortex-app/api'; +import { provider } from '@kortex-app/api'; import type { Container } from 'inversify'; import { InversifyBinding } from '/@/inject/inversify-binding'; @@ -34,7 +35,20 @@ export class ClaudeExtension { } async activate(): Promise { - this.#inversifyBinding = new InversifyBinding(this.#extensionContext); + const claudeProvider = provider.createProvider({ + name: 'Claude', + status: 'unknown', + id: 'claude', + images: { + icon: './icon.png', + logo: { + dark: './icon.png', + light: './icon.png', + }, + }, + }); + + this.#inversifyBinding = new InversifyBinding(claudeProvider, this.#extensionContext); this.#container = await this.#inversifyBinding.initBindings(); try { diff --git a/extensions/claude/src/inject/inversify-binding.spec.ts b/extensions/claude/src/inject/inversify-binding.spec.ts index 733e4dd37..007547c2c 100644 --- a/extensions/claude/src/inject/inversify-binding.spec.ts +++ b/extensions/claude/src/inject/inversify-binding.spec.ts @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { ExtensionContext } from '@kortex-app/api'; +import type { ExtensionContext, Provider } from '@kortex-app/api'; import { Container } from 'inversify'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -24,10 +24,11 @@ import { managersModule } from '/@/manager/_manager-module'; import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager'; import { InversifyBinding } from './inversify-binding'; -import { ExtensionContextSymbol } from './symbol'; +import { ClaudeProviderSymbol, ExtensionContextSymbol } from './symbol'; let inversifyBinding: InversifyBinding; +const providerMock = {} as Provider; const extensionContextMock = {} as ExtensionContext; vi.mock(import('inversify')); @@ -35,7 +36,7 @@ vi.mock(import('inversify')); describe('InversifyBinding', () => { beforeEach(() => { vi.resetAllMocks(); - inversifyBinding = new InversifyBinding(extensionContextMock); + inversifyBinding = new InversifyBinding(providerMock, extensionContextMock); vi.mocked(Container.prototype.bind).mockReturnValue({ toConstantValue: vi.fn(), } as unknown as ReturnType); @@ -47,6 +48,7 @@ describe('InversifyBinding', () => { await container.getAsync(ClaudeSkillsManager); expect(vi.mocked(Container.prototype.bind)).toHaveBeenCalledWith(ExtensionContextSymbol); + expect(vi.mocked(Container.prototype.bind)).toHaveBeenCalledWith(ClaudeProviderSymbol); expect(vi.mocked(Container.prototype.load)).toHaveBeenCalledWith(managersModule); }); diff --git a/extensions/claude/src/inject/inversify-binding.ts b/extensions/claude/src/inject/inversify-binding.ts index ed8e7d967..8ef0b5b3a 100644 --- a/extensions/claude/src/inject/inversify-binding.ts +++ b/extensions/claude/src/inject/inversify-binding.ts @@ -16,19 +16,21 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { ExtensionContext } from '@kortex-app/api'; +import type { ExtensionContext, Provider } from '@kortex-app/api'; import { Container } from 'inversify'; -import { ExtensionContextSymbol } from '/@/inject/symbol'; +import { ClaudeProviderSymbol, ExtensionContextSymbol } from '/@/inject/symbol'; import { managersModule } from '/@/manager/_manager-module'; import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager'; export class InversifyBinding { #container: Container | undefined; + readonly #provider: Provider; readonly #extensionContext: ExtensionContext; - constructor(extensionContext: ExtensionContext) { + constructor(provider: Provider, extensionContext: ExtensionContext) { + this.#provider = provider; this.#extensionContext = extensionContext; } @@ -36,6 +38,7 @@ export class InversifyBinding { this.#container = new Container(); this.#container.bind(ExtensionContextSymbol).toConstantValue(this.#extensionContext); + this.#container.bind(ClaudeProviderSymbol).toConstantValue(this.#provider); await this.#container.load(managersModule); diff --git a/extensions/claude/src/inject/symbol.ts b/extensions/claude/src/inject/symbol.ts index 642b6c307..1a9b0685e 100644 --- a/extensions/claude/src/inject/symbol.ts +++ b/extensions/claude/src/inject/symbol.ts @@ -17,3 +17,4 @@ ***********************************************************************/ export const ExtensionContextSymbol = Symbol.for('ExtensionContext'); +export const ClaudeProviderSymbol = Symbol.for('ClaudeProvider'); diff --git a/extensions/claude/src/manager/claude-skills-manager.spec.ts b/extensions/claude/src/manager/claude-skills-manager.spec.ts index 7477f5117..290e5c53a 100644 --- a/extensions/claude/src/manager/claude-skills-manager.spec.ts +++ b/extensions/claude/src/manager/claude-skills-manager.spec.ts @@ -19,36 +19,35 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { Disposable, ExtensionContext } from '@kortex-app/api'; -import * as extensionApi from '@kortex-app/api'; +import type { Disposable, Provider } from '@kortex-app/api'; import { Container } from 'inversify'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { ExtensionContextSymbol } from '/@/inject/symbol'; +import { ClaudeProviderSymbol } from '/@/inject/symbol'; import { ClaudeSkillsManager } from './claude-skills-manager'; vi.mock(import('node:os')); -vi.mock(import('@kortex-app/api')); const MOCK_HOME = '/home/testuser'; const CLAUDE_SKILLS_DIR = join(MOCK_HOME, '.claude', 'skills'); +const disposableMock: Disposable = { dispose: vi.fn() }; +const providerMock: Provider = { + registerSkill: vi.fn(), +} as unknown as Provider; + describe('ClaudeSkillsManager', () => { let claudeSkillsManager: ClaudeSkillsManager; - let extensionContextMock: ExtensionContext; beforeEach(async () => { vi.resetAllMocks(); vi.mocked(homedir).mockReturnValue(MOCK_HOME); - - extensionContextMock = { - subscriptions: [], - } as unknown as ExtensionContext; + vi.mocked(providerMock.registerSkill).mockReturnValue(disposableMock); const container = new Container(); container.bind(ClaudeSkillsManager).toSelf(); - container.bind(ExtensionContextSymbol).toConstantValue(extensionContextMock); + container.bind(ClaudeProviderSymbol).toConstantValue(providerMock); claudeSkillsManager = await container.getAsync(ClaudeSkillsManager); }); @@ -56,36 +55,19 @@ describe('ClaudeSkillsManager', () => { expect(ClaudeSkillsManager.getClaudeSkillsDir()).toBe(CLAUDE_SKILLS_DIR); }); - test('registers skill folder with correct parameters', async () => { - const mockDisposable: Disposable = { dispose: vi.fn() }; - vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); - + test('registers skill with correct parameters via provider', async () => { await claudeSkillsManager.init(); - expect(extensionApi.skills.registerSkillFolder).toHaveBeenCalledWith({ - label: 'Claude Skills', - badge: 'Claude', - icon: './icon.png', - baseDirectory: CLAUDE_SKILLS_DIR, + expect(providerMock.registerSkill).toHaveBeenCalledWith({ + label: 'Claude', + path: CLAUDE_SKILLS_DIR, }); }); - test('pushes disposable to extension context subscriptions', async () => { - const mockDisposable: Disposable = { dispose: vi.fn() }; - vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); - - await claudeSkillsManager.init(); - - expect(extensionContextMock.subscriptions).toContain(mockDisposable); - }); - - test('dispose cleans up skill folder registration', async () => { - const mockDisposable: Disposable = { dispose: vi.fn() }; - vi.mocked(extensionApi.skills.registerSkillFolder).mockReturnValue(mockDisposable); - + test('dispose cleans up skill registration', async () => { await claudeSkillsManager.init(); claudeSkillsManager.dispose(); - expect(mockDisposable.dispose).toHaveBeenCalled(); + expect(disposableMock.dispose).toHaveBeenCalled(); }); }); diff --git a/extensions/claude/src/manager/claude-skills-manager.ts b/extensions/claude/src/manager/claude-skills-manager.ts index 349613eb9..04d7bfb60 100644 --- a/extensions/claude/src/manager/claude-skills-manager.ts +++ b/extensions/claude/src/manager/claude-skills-manager.ts @@ -19,18 +19,17 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { Disposable, ExtensionContext } from '@kortex-app/api'; -import { skills } from '@kortex-app/api'; +import type { Disposable, Provider } from '@kortex-app/api'; import { inject, injectable } from 'inversify'; -import { ExtensionContextSymbol } from '/@/inject/symbol'; +import { ClaudeProviderSymbol } from '/@/inject/symbol'; @injectable() export class ClaudeSkillsManager { - @inject(ExtensionContextSymbol) - private extensionContext: ExtensionContext; + @inject(ClaudeProviderSymbol) + private claudeProvider: Provider; - #skillFolderDisposable: Disposable | undefined; + #skillDisposable: Disposable | undefined; static getClaudeSkillsDir(): string { return join(homedir(), '.claude', 'skills'); @@ -39,17 +38,14 @@ export class ClaudeSkillsManager { async init(): Promise { const claudeSkillsDir = ClaudeSkillsManager.getClaudeSkillsDir(); - this.#skillFolderDisposable = skills.registerSkillFolder({ - label: 'Claude Skills', - badge: 'Claude', - icon: './icon.png', - baseDirectory: claudeSkillsDir, + this.#skillDisposable = this.claudeProvider.registerSkill({ + label: 'Claude', + path: claudeSkillsDir, }); - this.extensionContext.subscriptions.push(this.#skillFolderDisposable); } dispose(): void { - this.#skillFolderDisposable?.dispose(); - this.#skillFolderDisposable = undefined; + this.#skillDisposable?.dispose(); + this.#skillDisposable = undefined; } } diff --git a/packages/extension-api/src/extension-api.d.ts b/packages/extension-api/src/extension-api.d.ts index 6ef98478b..516b860ad 100644 --- a/packages/extension-api/src/extension-api.d.ts +++ b/packages/extension-api/src/extension-api.d.ts @@ -5510,28 +5510,4 @@ declare module '@kortex-app/api' { export namespace rag { export function registerChunkProvider(provider: ChunkProvider): Disposable; } - - export interface SkillFolderRegistration { - label: string; - badge: string; - icon?: string; - baseDirectory: string; - } - - export namespace skills { - /** - * Registers a skill folder that provides a storage location for skills. - * Extensions use this to contribute their own skill directories (e.g. Claude, Cursor). - * The folder is automatically unregistered when the extension is deactivated. - * @param folder The folder registration containing label, badge, and base directory - */ - export function registerSkillFolder(folder: SkillFolderRegistration): Disposable; - - /** - * Registers a skill from an external folder containing a SKILL.md file. - * The folder is not copied; only a reference is stored. - * @param folderPath Absolute path to the folder containing SKILL.md - */ - export function registerSkill(folderPath: string): Promise; - } } diff --git a/packages/main/src/plugin/extension/extension-loader.spec.ts b/packages/main/src/plugin/extension/extension-loader.spec.ts index 74caf5b0e..d00524574 100644 --- a/packages/main/src/plugin/extension/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension/extension-loader.spec.ts @@ -33,7 +33,6 @@ import type { FeatureRegistry } from '/@/plugin/feature-registry.js'; import type { KubeGeneratorRegistry } from '/@/plugin/kubernetes/kube-generator-registry.js'; import type { MCPRegistry } from '/@/plugin/mcp/mcp-registry.js'; import { NavigationManager } from '/@/plugin/navigation/navigation-manager.js'; -import type { SkillManager } from '/@/plugin/skill/skill-manager.js'; import type { WebviewRegistry } from '/@/plugin/webview/webview-registry.js'; import type { ApiSenderType } from '/@api/api-sender/api-sender-type.js'; import type { ContributionInfo } from '/@api/contribution-info.js'; @@ -166,11 +165,6 @@ const featureRegistry: FeatureRegistry = { const mcpRegistry: MCPRegistry = {} as unknown as MCPRegistry; -const skillManager: SkillManager = { - registerSkill: vi.fn(), - unregisterSkill: vi.fn(), -} as unknown as SkillManager; - const apiSender: ApiSenderType = { send: vi.fn() } as unknown as ApiSenderType; const trayMenuRegistry: TrayMenuRegistry = {} as unknown as TrayMenuRegistry; @@ -402,7 +396,6 @@ beforeEach(() => { featureRegistry, mcpRegistry, chunkProviderRegistry, - skillManager, ); }); diff --git a/packages/main/src/plugin/extension/extension-loader.ts b/packages/main/src/plugin/extension/extension-loader.ts index 82b71108a..a34997cca 100644 --- a/packages/main/src/plugin/extension/extension-loader.ts +++ b/packages/main/src/plugin/extension/extension-loader.ts @@ -81,7 +81,6 @@ import { ProviderRegistry } from '../provider-registry.js'; import { Proxy } from '../proxy.js'; import { createHttpPatchedModules } from '../proxy-resolver.js'; import { SafeStorageRegistry } from '../safe-storage/safe-storage-registry.js'; -import { SkillManager } from '../skill/skill-manager.js'; import { StatusBarAlignLeft, StatusBarAlignRight, @@ -233,8 +232,6 @@ export class ExtensionLoader implements IAsyncDisposable { private mcpRegistry: MCPRegistry, @inject(ChunkProviderRegistry) private chunkProviderRegistry: ChunkProviderRegistry, - @inject(SkillManager) - private skillManager: SkillManager, ) { this.pluginsDirectory = directories.getPluginsDirectory(); this.pluginsScanDirectory = directories.getPluginsScanDirectory(); @@ -1700,22 +1697,6 @@ export class ExtensionLoader implements IAsyncDisposable { }, }; - const skills: typeof containerDesktopAPI.skills = { - registerSkillFolder: (folder: containerDesktopAPI.SkillFolderRegistration): containerDesktopAPI.Disposable => { - let resolvedIcon: string | undefined; - if (folder.icon) { - const img = instance.updateImage(folder.icon, extensionPath); - resolvedIcon = typeof img === 'string' ? img : img?.light; - } - const disposable = this.skillManager.registerSkillFolder({ ...folder, icon: resolvedIcon }); - disposables.push(disposable); - return disposable; - }, - registerSkill: async (folderPath: string): Promise => { - await this.skillManager.registerSkill(folderPath); - }, - }; - return { // Types Disposable: Disposable, @@ -1753,7 +1734,6 @@ export class ExtensionLoader implements IAsyncDisposable { net, mcpRegistry, rag, - skills, }; } diff --git a/packages/main/src/plugin/skill/skill-manager.spec.ts b/packages/main/src/plugin/skill/skill-manager.spec.ts index 127155e46..ffbe30e94 100644 --- a/packages/main/src/plugin/skill/skill-manager.spec.ts +++ b/packages/main/src/plugin/skill/skill-manager.spec.ts @@ -315,32 +315,14 @@ test('parseSkillFile should throw when description contains XML tags', async () ); }); -test('registerSkill should return a disposable that unregisters the skill', async () => { +test('registerSkill should reference the original folder without copying', async () => { vi.mocked(existsSync).mockImplementation(p => String(p).endsWith(SKILL_FILE_NAME)); vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); await skillManager.init(); const externalPath = resolve('/my/skill/folder'); - const disposable = await skillManager.registerSkill(externalPath); - - expect(disposable).toBeDefined(); - expect(disposable.dispose).toBeDefined(); - expect(skillManager.listSkills()).toHaveLength(1); - - disposable.dispose(); - - expect(skillManager.listSkills()).toHaveLength(0); -}); - -test('addSkill should reference the original folder without copying', async () => { - vi.mocked(existsSync).mockImplementation(p => String(p).endsWith(SKILL_FILE_NAME)); - vi.mocked(readFile).mockResolvedValue(validSkillMd); - - const skillManager = createSkillManager(); - await skillManager.init(); - const externalPath = resolve('/my/skill/folder'); - const skill = await skillManager.addSkill(externalPath); + const skill = await skillManager.registerSkill(externalPath); expect(skill.name).toBe('my-test-skill'); expect(skill.description).toBe('A test skill for unit testing'); @@ -376,7 +358,7 @@ test('disableSkill should disable a registered skill', async () => { const skillManager = createSkillManager(); await skillManager.init(); - const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(apiSender.send).mockClear(); updateMock.mockClear(); @@ -394,7 +376,7 @@ test('enableSkill should re-enable a disabled skill', async () => { const skillManager = createSkillManager(); await skillManager.init(); - const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); skillManager.disableSkill(skill.name); expect(skillManager.listSkills().at(0)?.enabled).toBe(false); @@ -427,7 +409,7 @@ test('unregisterSkill should delete folder for managed skills', async () => { const skillManager = createSkillManager(); await skillManager.init(); - const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(apiSender.send).mockClear(); updateMock.mockClear(); @@ -486,7 +468,7 @@ test('getSkillContent should return the SKILL.md content for a registered skill' vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); - const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(readFile).mockResolvedValue('full markdown content'); @@ -506,7 +488,7 @@ test('listSkillFolderContent should return folder entries for a registered skill vi.mocked(readFile).mockResolvedValue(validSkillMd); const skillManager = createSkillManager(); - const skill = await skillManager.addSkill(join(SKILLS_DIR, 'my-test-skill')); + const skill = await skillManager.registerSkill(join(SKILLS_DIR, 'my-test-skill')); vi.mocked(readdir).mockResolvedValue(['SKILL.md', 'utils.ts', 'templates'] as unknown as Awaited< ReturnType diff --git a/packages/main/src/plugin/skill/skill-manager.ts b/packages/main/src/plugin/skill/skill-manager.ts index f03fb8321..5f532a75a 100644 --- a/packages/main/src/plugin/skill/skill-manager.ts +++ b/packages/main/src/plugin/skill/skill-manager.ts @@ -26,7 +26,6 @@ import { dump, load } from 'js-yaml'; import { IPCHandle } from '/@/plugin/api.js'; import { Directories } from '/@/plugin/directories.js'; -import { Disposable } from '/@/plugin/types/disposable.js'; import { ApiSenderType } from '/@api/api-sender/api-sender-type.js'; import type { IConfigurationNode } from '/@api/configuration/models.js'; import { IConfigurationRegistry } from '/@api/configuration/models.js'; @@ -103,7 +102,7 @@ export class SkillManager { }); this.ipcHandle('skill-manager:registerSkill', async (_listener, folderPath: string): Promise => { - return this.addSkill(folderPath); + return this.registerSkill(folderPath); }); this.ipcHandle('skill-manager:disableSkill', async (_listener, name: string): Promise => { @@ -217,14 +216,7 @@ export class SkillManager { * path. The original folder is not copied. The skill is enabled immediately * and the reference is persisted to config. */ - async registerSkill(folderPath: string): Promise { - const skill = await this.addSkill(folderPath); - return Disposable.create(() => { - this.unregisterSkill(skill.name).catch(console.error); - }); - } - - async addSkill(folderPath: string): Promise { + async registerSkill(folderPath: string): Promise { const resolvedPath = resolve(folderPath); const skillFilePath = join(resolvedPath, SKILL_FILE_NAME);