From 14a38212c23aec287c8711e926c60df32dc4cd0c Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:51:58 +0300 Subject: [PATCH] fix(extensions): gate codex skill overlays by runtime --- .../extensions/skills/SkillEditorDialog.tsx | 20 ++++- .../extensions/skills/SkillImportDialog.tsx | 14 ++- .../extensions/skills/SkillsPanel.tsx | 27 ++++-- src/shared/utils/skillRoots.ts | 23 +++++ .../skills/SkillEditorDialog.test.ts | 88 +++++++++++++++++++ .../skills/SkillImportDialog.test.ts | 38 ++++++++ .../extensions/skills/SkillsPanel.test.ts | 65 +++++++++++++- 7 files changed, 264 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index 6375f148..b377ac16 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -52,6 +52,7 @@ interface SkillEditorDialogProps { mode: EditorMode; projectPath: string | null; projectLabel: string | null; + allowCodexRootKind: boolean; detail: SkillDetail | null; onClose: () => void; onSaved: (skillId: string | null) => void; @@ -70,6 +71,7 @@ export const SkillEditorDialog = ({ mode, projectPath, projectLabel, + allowCodexRootKind, detail, onClose, onSaved, @@ -220,7 +222,7 @@ export const SkillEditorDialog = ({ setReviewLoading(false); setSaveLoading(false); setMutationError(null); - }, [detail, mode, open, projectPath]); + }, [allowCodexRootKind, detail, mode, open, projectPath]); useEffect(() => { if (open) { @@ -240,6 +242,12 @@ export const SkillEditorDialog = ({ } }, [mode, open, projectPath, scope]); + useEffect(() => { + if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) { + setRootKind('claude'); + } + }, [allowCodexRootKind, mode, open, rootKind]); + useEffect(() => { rawContentRef.current = rawContent; }, [rawContent]); @@ -291,6 +299,14 @@ export const SkillEditorDialog = ({ ); const canUseProjectScope = Boolean(projectPath); + const visibleRootDefinitions = useMemo( + () => + SKILL_ROOT_DEFINITIONS.filter( + (definition) => + definition.rootKind !== 'codex' || allowCodexRootKind || detail?.item.rootKind === 'codex' + ), + [allowCodexRootKind, detail?.item.rootKind] + ); const instructionsLocked = manualRawEdit || customMarkdownDetected; const title = mode === 'create' ? 'Create skill' : 'Edit skill'; const descriptionText = @@ -436,7 +452,7 @@ export const SkillEditorDialog = ({ - {SKILL_ROOT_DEFINITIONS.map((definition) => ( + {visibleRootDefinitions.map((definition) => ( {definition.directoryName} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index 942cbdd9..5270af35 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -56,6 +56,7 @@ interface SkillImportDialogProps { open: boolean; projectPath: string | null; projectLabel: string | null; + allowCodexRootKind: boolean; onClose: () => void; onImported: (skillId: string | null) => void; } @@ -64,6 +65,7 @@ export const SkillImportDialog = ({ open, projectPath, projectLabel, + allowCodexRootKind, onClose, onImported, }: SkillImportDialogProps): React.JSX.Element => { @@ -120,6 +122,16 @@ export const SkillImportDialog = ({ } }, [open, projectPath, scope]); + useEffect(() => { + if (open && rootKind === 'codex' && !allowCodexRootKind) { + setRootKind('claude'); + } + }, [allowCodexRootKind, open, rootKind]); + + const visibleRootDefinitions = SKILL_ROOT_DEFINITIONS.filter( + (definition) => definition.rootKind !== 'codex' || allowCodexRootKind + ); + async function handleChooseFolder(): Promise { const selected = await api.config.selectFolders(); const first = selected[0]; @@ -280,7 +292,7 @@ export const SkillImportDialog = ({ - {SKILL_ROOT_DEFINITIONS.map((definition) => ( + {visibleRootDefinitions.map((definition) => ( {definition.directoryName} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 474c08d4..cd9fabe5 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -10,6 +10,7 @@ import { formatSkillRootKind, getSkillAudience, getSkillAudienceLabel, + isCodexSkillOverlayAvailable, } from '@shared/utils/skillRoots'; import { AlertTriangle, @@ -126,11 +127,16 @@ export const SkillsPanel = ({ () => [...projectSkills, ...userSkills], [projectSkills, userSkills] ); + const codexSkillOverlayAvailable = useMemo( + () => isCodexSkillOverlayAvailable(cliStatus), + [cliStatus] + ); const codexOnlySkillsCount = useMemo( () => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length, [mergedSkills] ); const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount; + const showCodexOnlyUi = codexSkillOverlayAvailable || codexOnlySkillsCount > 0; const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null; selectedSkillItemRef.current = selectedSkillId ? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null) @@ -266,8 +272,10 @@ export const SkillsPanel = ({

Use personal skills for habits you want everywhere. Use project skills for workflows - that only make sense inside one codebase. Use `.codex` when a skill should stay - Codex-only. + that only make sense inside one codebase. + {codexSkillOverlayAvailable + ? ' Use `.codex` when a skill should stay Codex-only.' + : ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}

@@ -348,9 +356,11 @@ export const SkillsPanel = ({ {sharedSkillsCount} shared - - {codexOnlySkillsCount} Codex only - + {showCodexOnlyUi && ( + + {codexOnlySkillsCount} Codex only + + )} @@ -363,7 +373,9 @@ export const SkillsPanel = ({ ['project', 'Project'], ['personal', 'Personal'], ['shared', 'Shared'], - ['codex-only', 'Codex only'], + ...(showCodexOnlyUi + ? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][]) + : []), ['needs-attention', 'Needs attention'], ['has-scripts', 'Has scripts'], ] as [SkillsQuickFilter, string][] @@ -616,6 +628,7 @@ export const SkillsPanel = ({ mode="create" projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} detail={null} onClose={() => setCreateOpen(false)} onSaved={(skillId) => { @@ -631,6 +644,7 @@ export const SkillsPanel = ({ mode="edit" projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} detail={editingDetail} onClose={() => { setEditOpen(false); @@ -648,6 +662,7 @@ export const SkillsPanel = ({ open={importOpen} projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} onClose={() => setImportOpen(false)} onImported={(skillId) => { setImportOpen(false); diff --git a/src/shared/utils/skillRoots.ts b/src/shared/utils/skillRoots.ts index e2f84a42..4a0e3ae6 100644 --- a/src/shared/utils/skillRoots.ts +++ b/src/shared/utils/skillRoots.ts @@ -1,4 +1,10 @@ +import { + getCliProviderExtensionCapability, + isCliExtensionCapabilityAvailable, +} from './providerExtensionCapabilities'; + import type { TeamProviderId } from '@shared/types'; +import type { CliInstallationStatus } from '@shared/types'; import type { SkillRootKind } from '@shared/types/extensions'; export type SkillAudience = 'shared' | 'codex'; @@ -59,3 +65,20 @@ export function isSkillAvailableForProvider( ): boolean { return getSkillAudience(rootKind) === 'shared' || providerId === 'codex'; } + +export function isCodexSkillOverlayAvailable( + cliStatus: Pick | null | undefined +): boolean { + if (cliStatus?.flavor !== 'agent_teams_orchestrator') { + return false; + } + + const codexProvider = cliStatus.providers.find((provider) => provider.providerId === 'codex'); + if (!codexProvider?.supported) { + return false; + } + + return isCliExtensionCapabilityAvailable( + getCliProviderExtensionCapability(codexProvider, 'skills') + ); +} diff --git a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts index fdf1f0a0..60cc722d 100644 --- a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts @@ -181,6 +181,20 @@ function makeDetail(rawContent: string): SkillDetail { }; } +function makeCodexDetail(rawContent: string): SkillDetail { + const detail = makeDetail(rawContent); + return { + ...detail, + item: { + ...detail.item, + rootKind: 'codex', + discoveryRoot: '/tmp/project/.codex/skills', + skillDir: '/tmp/project/.codex/skills/custom-skill', + skillFile: '/tmp/project/.codex/skills/custom-skill/SKILL.md', + }, + }; +} + describe('SkillEditorDialog', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -214,6 +228,7 @@ This file uses a freeform layout without generated sections. mode: 'edit', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail, onClose: vi.fn(), onSaved: vi.fn(), @@ -272,6 +287,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -298,6 +314,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -326,6 +343,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -360,4 +378,74 @@ This file uses a freeform layout without generated sections. await Promise.resolve(); }); }); + + it('hides the codex root option in create mode when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'create', + projectPath: '/tmp/project', + projectLabel: 'Project', + allowCodexRootKind: false, + detail: null, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the codex root visible when editing an existing codex-only skill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const detail = makeCodexDetail(`--- +name: Codex Skill +description: Codex markdown skill +--- + +# Codex Skill +`); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'edit', + projectPath: '/tmp/project', + projectLabel: 'Project', + allowCodexRootKind: false, + detail, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(rootSelect.value).toBe('codex'); + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts index 46a43035..5d572d23 100644 --- a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -132,6 +132,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -167,6 +168,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -232,6 +234,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -268,6 +271,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: '/tmp/project-a', projectLabel: 'Project A', + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -284,6 +288,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -332,6 +337,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -364,6 +370,7 @@ describe('SkillImportDialog', () => { open: false, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -390,6 +397,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -438,6 +446,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -471,4 +480,33 @@ describe('SkillImportDialog', () => { await Promise.resolve(); }); }); + + it('hides the codex root option when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + allowCodexRootKind: false, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts index b78e559d..d8864a79 100644 --- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts +++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts @@ -2,6 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CliInstallationStatus } from '@shared/types'; import type { SkillCatalogItem } from '@shared/types/extensions'; interface StoreState { @@ -12,6 +13,7 @@ interface StoreState { skillsDetailsById: Record; skillsUserCatalog: SkillCatalogItem[]; skillsProjectCatalogByProjectPath: Record; + cliStatus: CliInstallationStatus | null; } const storeState = {} as StoreState; @@ -107,11 +109,19 @@ vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({ })); vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({ - SkillEditorDialog: () => null, + SkillEditorDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => + React.createElement('div', { + 'data-testid': 'skill-editor-dialog', + 'data-allow-codex-root-kind': String(allowCodexRootKind), + }), })); vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({ - SkillImportDialog: () => null, + SkillImportDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => + React.createElement('div', { + 'data-testid': 'skill-import-dialog', + 'data-allow-codex-root-kind': String(allowCodexRootKind), + }), })); vi.mock('lucide-react', () => { @@ -170,6 +180,22 @@ describe('SkillsPanel', () => { storeState.skillsProjectCatalogByProjectPath = { '/tmp/project-a': [], }; + storeState.cliStatus = { + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth', + providers: [], + }; startWatchingMock.mockReset(); stopWatchingMock.mockReset(); onChangedMock.mockReset(); @@ -232,4 +258,39 @@ describe('SkillsPanel', () => { await Promise.resolve(); }); }); + + it('hides codex-only create and import affordances when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillsPanel, { + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + skillsSearchQuery: '', + setSkillsSearchQuery: vi.fn(), + skillsSort: 'name-asc', + setSkillsSort: vi.fn(), + selectedSkillId: null, + setSelectedSkillId: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Codex only'); + for (const node of host.querySelectorAll('[data-testid="skill-editor-dialog"]')) { + expect(node.getAttribute('data-allow-codex-root-kind')).toBe('false'); + } + const importDialog = host.querySelector('[data-testid="skill-import-dialog"]'); + expect(importDialog?.getAttribute('data-allow-codex-root-kind')).toBe('false'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); });