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;
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 = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{SKILL_ROOT_DEFINITIONS.map((definition) => (
{visibleRootDefinitions.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}

View file

@ -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<void> {
const selected = await api.config.selectFolders();
const first = selected[0];
@ -280,7 +292,7 @@ export const SkillImportDialog = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{SKILL_ROOT_DEFINITIONS.map((definition) => (
{visibleRootDefinitions.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}

View file

@ -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 = ({
</p>
<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
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.'}
</p>
</div>
@ -348,9 +356,11 @@ export const SkillsPanel = ({
<Badge variant="secondary" className="font-normal">
{sharedSkillsCount} shared
</Badge>
<Badge variant="secondary" className="font-normal">
{codexOnlySkillsCount} Codex only
</Badge>
{showCodexOnlyUi && (
<Badge variant="secondary" className="font-normal">
{codexOnlySkillsCount} Codex only
</Badge>
)}
</div>
</div>
</div>
@ -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);

View file

@ -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<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', () => {
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();
});
});
});

View file

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

View file

@ -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<string, unknown>;
skillsUserCatalog: SkillCatalogItem[];
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
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();
});
});
});