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();
+ });
+ });
});