fix(extensions): gate codex skill overlays by runtime

This commit is contained in:
777genius 2026-04-17 14:51:58 +03:00
parent 8423656b97
commit 14a38212c2
7 changed files with 264 additions and 11 deletions

View file

@ -52,6 +52,7 @@ interface SkillEditorDialogProps {
mode: EditorMode; mode: EditorMode;
projectPath: string | null; projectPath: string | null;
projectLabel: string | null; projectLabel: string | null;
allowCodexRootKind: boolean;
detail: SkillDetail | null; detail: SkillDetail | null;
onClose: () => void; onClose: () => void;
onSaved: (skillId: string | null) => void; onSaved: (skillId: string | null) => void;
@ -70,6 +71,7 @@ export const SkillEditorDialog = ({
mode, mode,
projectPath, projectPath,
projectLabel, projectLabel,
allowCodexRootKind,
detail, detail,
onClose, onClose,
onSaved, onSaved,
@ -220,7 +222,7 @@ export const SkillEditorDialog = ({
setReviewLoading(false); setReviewLoading(false);
setSaveLoading(false); setSaveLoading(false);
setMutationError(null); setMutationError(null);
}, [detail, mode, open, projectPath]); }, [allowCodexRootKind, detail, mode, open, projectPath]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -240,6 +242,12 @@ export const SkillEditorDialog = ({
} }
}, [mode, open, projectPath, scope]); }, [mode, open, projectPath, scope]);
useEffect(() => {
if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) {
setRootKind('claude');
}
}, [allowCodexRootKind, mode, open, rootKind]);
useEffect(() => { useEffect(() => {
rawContentRef.current = rawContent; rawContentRef.current = rawContent;
}, [rawContent]); }, [rawContent]);
@ -291,6 +299,14 @@ export const SkillEditorDialog = ({
); );
const canUseProjectScope = Boolean(projectPath); 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 instructionsLocked = manualRawEdit || customMarkdownDetected;
const title = mode === 'create' ? 'Create skill' : 'Edit skill'; const title = mode === 'create' ? 'Create skill' : 'Edit skill';
const descriptionText = const descriptionText =
@ -436,7 +452,7 @@ export const SkillEditorDialog = ({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SKILL_ROOT_DEFINITIONS.map((definition) => ( {visibleRootDefinitions.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}> <SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName} {definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}

View file

@ -56,6 +56,7 @@ interface SkillImportDialogProps {
open: boolean; open: boolean;
projectPath: string | null; projectPath: string | null;
projectLabel: string | null; projectLabel: string | null;
allowCodexRootKind: boolean;
onClose: () => void; onClose: () => void;
onImported: (skillId: string | null) => void; onImported: (skillId: string | null) => void;
} }
@ -64,6 +65,7 @@ export const SkillImportDialog = ({
open, open,
projectPath, projectPath,
projectLabel, projectLabel,
allowCodexRootKind,
onClose, onClose,
onImported, onImported,
}: SkillImportDialogProps): React.JSX.Element => { }: SkillImportDialogProps): React.JSX.Element => {
@ -120,6 +122,16 @@ export const SkillImportDialog = ({
} }
}, [open, projectPath, scope]); }, [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<void> { async function handleChooseFolder(): Promise<void> {
const selected = await api.config.selectFolders(); const selected = await api.config.selectFolders();
const first = selected[0]; const first = selected[0];
@ -280,7 +292,7 @@ export const SkillImportDialog = ({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SKILL_ROOT_DEFINITIONS.map((definition) => ( {visibleRootDefinitions.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}> <SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName} {definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}

View file

@ -10,6 +10,7 @@ import {
formatSkillRootKind, formatSkillRootKind,
getSkillAudience, getSkillAudience,
getSkillAudienceLabel, getSkillAudienceLabel,
isCodexSkillOverlayAvailable,
} from '@shared/utils/skillRoots'; } from '@shared/utils/skillRoots';
import { import {
AlertTriangle, AlertTriangle,
@ -126,11 +127,16 @@ export const SkillsPanel = ({
() => [...projectSkills, ...userSkills], () => [...projectSkills, ...userSkills],
[projectSkills, userSkills] [projectSkills, userSkills]
); );
const codexSkillOverlayAvailable = useMemo(
() => isCodexSkillOverlayAvailable(cliStatus),
[cliStatus]
);
const codexOnlySkillsCount = useMemo( const codexOnlySkillsCount = useMemo(
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length, () => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
[mergedSkills] [mergedSkills]
); );
const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount; const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount;
const showCodexOnlyUi = codexSkillOverlayAvailable || codexOnlySkillsCount > 0;
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null; const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
selectedSkillItemRef.current = selectedSkillId selectedSkillItemRef.current = selectedSkillId
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null) ? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
@ -266,8 +272,10 @@ export const SkillsPanel = ({
</p> </p>
<p className="max-w-2xl text-xs leading-5 text-text-muted"> <p className="max-w-2xl text-xs leading-5 text-text-muted">
Use personal skills for habits you want everywhere. Use project skills for workflows 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 that only make sense inside one codebase.
Codex-only. {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.'}
</p> </p>
</div> </div>
@ -348,9 +356,11 @@ export const SkillsPanel = ({
<Badge variant="secondary" className="font-normal"> <Badge variant="secondary" className="font-normal">
{sharedSkillsCount} shared {sharedSkillsCount} shared
</Badge> </Badge>
<Badge variant="secondary" className="font-normal"> {showCodexOnlyUi && (
{codexOnlySkillsCount} Codex only <Badge variant="secondary" className="font-normal">
</Badge> {codexOnlySkillsCount} Codex only
</Badge>
)}
</div> </div>
</div> </div>
</div> </div>
@ -363,7 +373,9 @@ export const SkillsPanel = ({
['project', 'Project'], ['project', 'Project'],
['personal', 'Personal'], ['personal', 'Personal'],
['shared', 'Shared'], ['shared', 'Shared'],
['codex-only', 'Codex only'], ...(showCodexOnlyUi
? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][])
: []),
['needs-attention', 'Needs attention'], ['needs-attention', 'Needs attention'],
['has-scripts', 'Has scripts'], ['has-scripts', 'Has scripts'],
] as [SkillsQuickFilter, string][] ] as [SkillsQuickFilter, string][]
@ -616,6 +628,7 @@ export const SkillsPanel = ({
mode="create" mode="create"
projectPath={projectPath} projectPath={projectPath}
projectLabel={projectLabel} projectLabel={projectLabel}
allowCodexRootKind={codexSkillOverlayAvailable}
detail={null} detail={null}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onSaved={(skillId) => { onSaved={(skillId) => {
@ -631,6 +644,7 @@ export const SkillsPanel = ({
mode="edit" mode="edit"
projectPath={projectPath} projectPath={projectPath}
projectLabel={projectLabel} projectLabel={projectLabel}
allowCodexRootKind={codexSkillOverlayAvailable}
detail={editingDetail} detail={editingDetail}
onClose={() => { onClose={() => {
setEditOpen(false); setEditOpen(false);
@ -648,6 +662,7 @@ export const SkillsPanel = ({
open={importOpen} open={importOpen}
projectPath={projectPath} projectPath={projectPath}
projectLabel={projectLabel} projectLabel={projectLabel}
allowCodexRootKind={codexSkillOverlayAvailable}
onClose={() => setImportOpen(false)} onClose={() => setImportOpen(false)}
onImported={(skillId) => { onImported={(skillId) => {
setImportOpen(false); setImportOpen(false);

View file

@ -1,4 +1,10 @@
import {
getCliProviderExtensionCapability,
isCliExtensionCapabilityAvailable,
} from './providerExtensionCapabilities';
import type { TeamProviderId } from '@shared/types'; import type { TeamProviderId } from '@shared/types';
import type { CliInstallationStatus } from '@shared/types';
import type { SkillRootKind } from '@shared/types/extensions'; import type { SkillRootKind } from '@shared/types/extensions';
export type SkillAudience = 'shared' | 'codex'; export type SkillAudience = 'shared' | 'codex';
@ -59,3 +65,20 @@ export function isSkillAvailableForProvider(
): boolean { ): boolean {
return getSkillAudience(rootKind) === 'shared' || providerId === 'codex'; return getSkillAudience(rootKind) === 'shared' || providerId === 'codex';
} }
export function isCodexSkillOverlayAvailable(
cliStatus: Pick<CliInstallationStatus, 'flavor' | 'providers'> | 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')
);
}

View file

@ -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', () => { describe('SkillEditorDialog', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
@ -214,6 +228,7 @@ This file uses a freeform layout without generated sections.
mode: 'edit', mode: 'edit',
projectPath: '/tmp/project', projectPath: '/tmp/project',
projectLabel: 'Project', projectLabel: 'Project',
allowCodexRootKind: true,
detail, detail,
onClose: vi.fn(), onClose: vi.fn(),
onSaved: vi.fn(), onSaved: vi.fn(),
@ -272,6 +287,7 @@ This file uses a freeform layout without generated sections.
mode: 'create', mode: 'create',
projectPath: '/tmp/project', projectPath: '/tmp/project',
projectLabel: 'Project', projectLabel: 'Project',
allowCodexRootKind: true,
detail: null, detail: null,
onClose: vi.fn(), onClose: vi.fn(),
onSaved: vi.fn(), onSaved: vi.fn(),
@ -298,6 +314,7 @@ This file uses a freeform layout without generated sections.
mode: 'create', mode: 'create',
projectPath: '/tmp/project', projectPath: '/tmp/project',
projectLabel: 'Project', projectLabel: 'Project',
allowCodexRootKind: true,
detail: null, detail: null,
onClose: vi.fn(), onClose: vi.fn(),
onSaved: vi.fn(), onSaved: vi.fn(),
@ -326,6 +343,7 @@ This file uses a freeform layout without generated sections.
mode: 'create', mode: 'create',
projectPath: '/tmp/project', projectPath: '/tmp/project',
projectLabel: 'Project', projectLabel: 'Project',
allowCodexRootKind: true,
detail: null, detail: null,
onClose: vi.fn(), onClose: vi.fn(),
onSaved: vi.fn(), onSaved: vi.fn(),
@ -360,4 +378,74 @@ This file uses a freeform layout without generated sections.
await Promise.resolve(); 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();
});
});
}); });

View file

@ -132,6 +132,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -167,6 +168,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -232,6 +234,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -268,6 +271,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: '/tmp/project-a', projectPath: '/tmp/project-a',
projectLabel: 'Project A', projectLabel: 'Project A',
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -284,6 +288,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -332,6 +337,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -364,6 +370,7 @@ describe('SkillImportDialog', () => {
open: false, open: false,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -390,6 +397,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -438,6 +446,7 @@ describe('SkillImportDialog', () => {
open: true, open: true,
projectPath: null, projectPath: null,
projectLabel: null, projectLabel: null,
allowCodexRootKind: true,
onClose: vi.fn(), onClose: vi.fn(),
onImported: vi.fn(), onImported: vi.fn(),
}) })
@ -471,4 +480,33 @@ describe('SkillImportDialog', () => {
await Promise.resolve(); 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();
});
});
}); });

View file

@ -2,6 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliInstallationStatus } from '@shared/types';
import type { SkillCatalogItem } from '@shared/types/extensions'; import type { SkillCatalogItem } from '@shared/types/extensions';
interface StoreState { interface StoreState {
@ -12,6 +13,7 @@ interface StoreState {
skillsDetailsById: Record<string, unknown>; skillsDetailsById: Record<string, unknown>;
skillsUserCatalog: SkillCatalogItem[]; skillsUserCatalog: SkillCatalogItem[];
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>; skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
cliStatus: CliInstallationStatus | null;
} }
const storeState = {} as StoreState; const storeState = {} as StoreState;
@ -107,11 +109,19 @@ vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({
})); }));
vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({ 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', () => ({ 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', () => { vi.mock('lucide-react', () => {
@ -170,6 +180,22 @@ describe('SkillsPanel', () => {
storeState.skillsProjectCatalogByProjectPath = { storeState.skillsProjectCatalogByProjectPath = {
'/tmp/project-a': [], '/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(); startWatchingMock.mockReset();
stopWatchingMock.mockReset(); stopWatchingMock.mockReset();
onChangedMock.mockReset(); onChangedMock.mockReset();
@ -232,4 +258,39 @@ describe('SkillsPanel', () => {
await Promise.resolve(); 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();
});
});
}); });