feat(extensions): add codex-only skills overlays

This commit is contained in:
777genius 2026-04-17 13:09:30 +03:00
parent f7c1dc16c4
commit e01858ac98
13 changed files with 237 additions and 53 deletions

View file

@ -1,6 +1,7 @@
import * as path from 'node:path';
import { getHomeDir } from '@main/utils/pathDecoder';
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
import type { SkillRootKind, SkillScope } from '@shared/types/extensions';
@ -11,11 +12,12 @@ export interface ResolvedSkillRoot {
rootPath: string;
}
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = [
{ rootKind: 'claude', segments: ['.claude', 'skills'] },
{ rootKind: 'cursor', segments: ['.cursor', 'skills'] },
{ rootKind: 'agents', segments: ['.agents', 'skills'] },
];
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = SKILL_ROOT_DEFINITIONS.map(
(definition) => ({
rootKind: definition.rootKind,
segments: [...definition.segments],
})
);
export class SkillRootsResolver {
resolve(projectPath?: string): ResolvedSkillRoot[] {

View file

@ -1,9 +1,12 @@
import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots';
import type { SkillCatalogItem } from '@shared/types/extensions';
const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
claude: 0,
cursor: 1,
agents: 2,
codex: 3,
};
export class SkillValidator {
@ -21,14 +24,14 @@ export class SkillValidator {
private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] {
const itemsByName = new Map<string, SkillCatalogItem[]>();
for (const item of items) {
const key = item.name.trim().toLowerCase();
const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
const bucket = itemsByName.get(key) ?? [];
bucket.push(item);
itemsByName.set(key, bucket);
}
return items.map((item) => {
const key = item.name.trim().toLowerCase();
const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
const duplicates = itemsByName.get(key) ?? [];
if (duplicates.length <= 1) {
return item;
@ -59,6 +62,7 @@ export class SkillValidator {
}
private formatRootLabel(item: SkillCatalogItem): string {
return item.scope === 'project' ? `project .${item.rootKind}` : `.${item.rootKind}`;
const rootLabel = formatSkillRootKind(item.rootKind);
return item.scope === 'project' ? `project ${rootLabel}` : rootLabel;
}
}

View file

@ -23,6 +23,7 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store';
import { formatSkillRootKind, getSkillAudienceLabel } from '@shared/utils/skillRoots';
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -81,18 +82,14 @@ export const SkillDetailDialog = ({
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
: (projectPath ?? undefined);
function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string {
return `.${rootKind}`;
}
function formatScopeLabel(scope: 'user' | 'project'): string {
return scope === 'project' ? 'This project only' : 'Your personal skills';
}
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
return invocationMode === 'manual-only'
? 'Claude will only use this when you explicitly ask for it.'
: 'Claude can pick this automatically when it matches the task.';
? 'Only runs when you explicitly ask for it.'
: 'Runs automatically when it matches the task.';
}
async function handleDelete(): Promise<void> {
@ -159,7 +156,8 @@ export const SkillDetailDialog = ({
)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatScopeLabel(item.scope)}</Badge>
<Badge variant="outline">Stored in {formatRootKind(item.rootKind)}</Badge>
<Badge variant="outline">Stored in {formatSkillRootKind(item.rootKind)}</Badge>
<Badge variant="outline">{getSkillAudienceLabel(item.rootKind)}</Badge>
<Badge variant="secondary">
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
</Badge>

View file

@ -23,6 +23,7 @@ import {
import { Textarea } from '@renderer/components/ui/textarea';
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
import { useStore } from '@renderer/store';
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
import { FileSearch, RotateCcw, X } from 'lucide-react';
import { SkillCodeEditor } from './SkillCodeEditor';
@ -40,6 +41,7 @@ import { validateSkillFolderName } from './skillValidationUtils';
import type {
SkillDetail,
SkillInvocationMode,
SkillRootKind,
SkillReviewPreview,
} from '@shared/types/extensions';
@ -79,7 +81,7 @@ export const SkillEditorDialog = ({
const applySkillUpsert = useStore((s) => s.applySkillUpsert);
const [scope, setScope] = useState<'user' | 'project'>('user');
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
const [rootKind, setRootKind] = useState<SkillRootKind>('claude');
const [folderName, setFolderName] = useState('');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
@ -427,18 +429,19 @@ export const SkillEditorDialog = ({
<Label htmlFor="skill-root">Where to store it</Label>
<Select
value={rootKind}
onValueChange={(value) =>
setRootKind(value as 'claude' | 'cursor' | 'agents')
}
onValueChange={(value) => setRootKind(value as SkillRootKind)}
disabled={mode === 'edit'}
>
<SelectTrigger id="skill-root">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">.claude</SelectItem>
<SelectItem value="cursor">.cursor</SelectItem>
<SelectItem value="agents">.agents</SelectItem>
{SKILL_ROOT_DEFINITIONS.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View file

@ -20,6 +20,7 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
import { FileSearch, FolderOpen, X } from 'lucide-react';
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
@ -27,7 +28,7 @@ import { SkillReviewDialog } from './SkillReviewDialog';
import { resolveSkillProjectPath } from './skillProjectUtils';
import { validateSkillFolderName, validateSkillImportSourceDir } from './skillValidationUtils';
import type { SkillReviewPreview } from '@shared/types/extensions';
import type { SkillReviewPreview, SkillRootKind } from '@shared/types/extensions';
function getFriendlyImportError(message: string): string {
if (message.includes('valid skill file')) {
@ -73,7 +74,7 @@ export const SkillImportDialog = ({
const [folderName, setFolderName] = useState('');
const [folderNameEdited, setFolderNameEdited] = useState(false);
const [scope, setScope] = useState<'user' | 'project'>('user');
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
const [rootKind, setRootKind] = useState<SkillRootKind>('claude');
const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewLoading, setReviewLoading] = useState(false);
@ -273,17 +274,18 @@ export const SkillImportDialog = ({
<Label htmlFor="skill-import-root">Where to store it</Label>
<Select
value={rootKind}
onValueChange={(value) =>
setRootKind(value as 'claude' | 'cursor' | 'agents')
}
onValueChange={(value) => setRootKind(value as SkillRootKind)}
>
<SelectTrigger id="skill-import-root">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">.claude</SelectItem>
<SelectItem value="cursor">.cursor</SelectItem>
<SelectItem value="agents">.agents</SelectItem>
{SKILL_ROOT_DEFINITIONS.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View file

@ -6,6 +6,11 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import {
formatSkillRootKind,
getSkillAudience,
getSkillAudienceLabel,
} from '@shared/utils/skillRoots';
import {
AlertTriangle,
ArrowUpAZ,
@ -33,7 +38,14 @@ import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
const SUCCESS_BANNER_MS = 2500;
const NEW_SKILL_HIGHLIGHT_MS = 4000;
const USER_SKILLS_CATALOG_KEY = '__user__';
type SkillsQuickFilter = 'all' | 'project' | 'personal' | 'needs-attention' | 'has-scripts';
type SkillsQuickFilter =
| 'all'
| 'project'
| 'personal'
| 'shared'
| 'codex-only'
| 'needs-attention'
| 'has-scripts';
interface SkillsPanelProps {
projectPath: string | null;
@ -57,10 +69,6 @@ function sortSkills(skills: SkillCatalogItem[], sort: SkillsSortState): SkillCat
return next;
}
function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string {
return `.${rootKind}`;
}
function getScopeLabel(skill: SkillCatalogItem): string {
return skill.scope === 'project' ? 'This project' : 'Personal';
}
@ -68,7 +76,7 @@ function getScopeLabel(skill: SkillCatalogItem): string {
function getInvocationLabel(skill: SkillCatalogItem): string {
return skill.invocationMode === 'manual-only'
? 'Only runs when you explicitly ask for it'
: 'Claude can use this automatically when it fits';
: 'Runs automatically when it fits';
}
function getSkillStatus(skill: SkillCatalogItem): string {
@ -118,6 +126,11 @@ export const SkillsPanel = ({
() => [...projectSkills, ...userSkills],
[projectSkills, userSkills]
);
const codexOnlySkillsCount = useMemo(
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
[mergedSkills]
);
const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount;
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
selectedSkillItemRef.current = selectedSkillId
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
@ -205,6 +218,10 @@ export const SkillsPanel = ({
return skill.scope === 'project';
case 'personal':
return skill.scope === 'user';
case 'shared':
return getSkillAudience(skill.rootKind) === 'shared';
case 'codex-only':
return getSkillAudience(skill.rootKind) === 'codex';
case 'needs-attention':
return !skill.isValid;
case 'has-scripts':
@ -229,8 +246,8 @@ export const SkillsPanel = ({
<div className="flex flex-col gap-4">
{cliStatus?.flavor === 'agent_teams_orchestrator' && (
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-300">
Skills are shared across providers in this runtime. A personal or project skill you edit
here is available to both Anthropic and Codex sessions that support skills.
Shared skills in `.claude`, `.cursor`, and `.agents` are available to both Anthropic and
Codex. Skills stored in `.codex` are only offered to Codex sessions.
</div>
)}
<div className="bg-surface-raised/20 rounded-xl border border-border p-4">
@ -249,7 +266,8 @@ 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.
that only make sense inside one codebase. Use `.codex` when a skill should stay
Codex-only.
</p>
</div>
@ -327,6 +345,12 @@ export const SkillsPanel = ({
<Badge variant="secondary" className="font-normal">
{userSkills.length} personal
</Badge>
<Badge variant="secondary" className="font-normal">
{sharedSkillsCount} shared
</Badge>
<Badge variant="secondary" className="font-normal">
{codexOnlySkillsCount} Codex only
</Badge>
</div>
</div>
</div>
@ -338,6 +362,8 @@ export const SkillsPanel = ({
['all', 'All skills'],
['project', 'Project'],
['personal', 'Personal'],
['shared', 'Shared'],
['codex-only', 'Codex only'],
['needs-attention', 'Needs attention'],
['has-scripts', 'Has scripts'],
] as [SkillsQuickFilter, string][]
@ -449,7 +475,10 @@ export const SkillsPanel = ({
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatRootKind(skill.rootKind)}
Stored in {formatSkillRootKind(skill.rootKind)}
</Badge>
<Badge variant="outline" className="font-normal">
{getSkillAudienceLabel(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">
@ -532,7 +561,10 @@ export const SkillsPanel = ({
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatRootKind(skill.rootKind)}
Stored in {formatSkillRootKind(skill.rootKind)}
</Badge>
<Badge variant="outline" className="font-normal">
{getSkillAudienceLabel(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">

View file

@ -237,7 +237,8 @@ export const MessageComposer = ({
buildSlashCommandSuggestions(
getSuggestedSlashCommandsForProvider(leadProviderId),
projectSkills,
userSkills
userSkills,
leadProviderId
),
[leadProviderId, projectSkills, userSkills]
);

View file

@ -1,13 +1,41 @@
import { getSkillAudienceLabel, isSkillAvailableForProvider } from '@shared/utils/skillRoots';
import { isSupportedSlashCommandName } from '@shared/utils/slashCommands';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { SkillCatalogItem } from '@shared/types/extensions';
import type { TeamProviderId } from '@shared/types';
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
function orderSkillsForProvider(
projectSkills: readonly SkillCatalogItem[],
userSkills: readonly SkillCatalogItem[],
providerId?: TeamProviderId
): SkillCatalogItem[] {
const visibleProjectSkills = projectSkills.filter((skill) =>
isSkillAvailableForProvider(skill.rootKind, providerId)
);
const visibleUserSkills = userSkills.filter((skill) =>
isSkillAvailableForProvider(skill.rootKind, providerId)
);
if (providerId !== 'codex') {
return [...visibleProjectSkills, ...visibleUserSkills];
}
const isCodexOnly = (skill: SkillCatalogItem) => skill.rootKind === 'codex';
return [
...visibleProjectSkills.filter(isCodexOnly),
...visibleProjectSkills.filter((skill) => !isCodexOnly(skill)),
...visibleUserSkills.filter(isCodexOnly),
...visibleUserSkills.filter((skill) => !isCodexOnly(skill)),
];
}
export function buildSlashCommandSuggestions(
builtIns: readonly KnownSlashCommandDefinition[],
projectSkills: readonly SkillCatalogItem[],
userSkills: readonly SkillCatalogItem[]
userSkills: readonly SkillCatalogItem[],
providerId?: TeamProviderId
): MentionSuggestion[] {
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
@ -21,7 +49,7 @@ export function buildSlashCommandSuggestions(
const seenSkillNames = new Set<string>();
const skillSuggestions: MentionSuggestion[] = [];
for (const skill of [...projectSkills, ...userSkills]) {
for (const skill of orderSkillsForProvider(projectSkills, userSkills, providerId)) {
const normalizedFolderName = skill.folderName.trim().toLowerCase();
if (
!skill.isValid ||
@ -39,7 +67,7 @@ export function buildSlashCommandSuggestions(
name: skill.folderName,
command: `/${normalizedFolderName}`,
description: skill.description,
subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill',
subtitle: `${skill.scope === 'project' ? 'Project skill' : 'Personal skill'} - ${getSkillAudienceLabel(skill.rootKind)}`,
searchText: `${skill.name} ${skill.folderName}`,
type: 'skill',
});

View file

@ -4,7 +4,7 @@
export type SkillScope = 'user' | 'project';
export type SkillRootKind = 'claude' | 'cursor' | 'agents';
export type SkillRootKind = 'claude' | 'cursor' | 'agents' | 'codex';
export type SkillSourceType = 'filesystem';

View file

@ -0,0 +1,61 @@
import type { TeamProviderId } from '@shared/types';
import type { SkillRootKind } from '@shared/types/extensions';
export type SkillAudience = 'shared' | 'codex';
export interface SkillRootDefinition {
rootKind: SkillRootKind;
directoryName: `.${string}`;
segments: [string, 'skills'];
audience: SkillAudience;
}
export const SKILL_ROOT_DEFINITIONS: readonly SkillRootDefinition[] = [
{
rootKind: 'claude',
directoryName: '.claude',
segments: ['.claude', 'skills'],
audience: 'shared',
},
{
rootKind: 'cursor',
directoryName: '.cursor',
segments: ['.cursor', 'skills'],
audience: 'shared',
},
{
rootKind: 'agents',
directoryName: '.agents',
segments: ['.agents', 'skills'],
audience: 'shared',
},
{
rootKind: 'codex',
directoryName: '.codex',
segments: ['.codex', 'skills'],
audience: 'codex',
},
] as const;
export function getSkillRootDefinition(rootKind: SkillRootKind): SkillRootDefinition {
return SKILL_ROOT_DEFINITIONS.find((definition) => definition.rootKind === rootKind)!;
}
export function formatSkillRootKind(rootKind: SkillRootKind): string {
return getSkillRootDefinition(rootKind).directoryName;
}
export function getSkillAudience(rootKind: SkillRootKind): SkillAudience {
return getSkillRootDefinition(rootKind).audience;
}
export function getSkillAudienceLabel(rootKind: SkillRootKind): string {
return getSkillAudience(rootKind) === 'codex' ? 'Codex only' : 'Shared';
}
export function isSkillAvailableForProvider(
rootKind: SkillRootKind,
providerId?: TeamProviderId
): boolean {
return getSkillAudience(rootKind) === 'shared' || providerId === 'codex';
}

View file

@ -8,9 +8,9 @@ describe('SkillRootsResolver', () => {
const roots = resolver.resolve();
expect(roots).toHaveLength(3);
expect(roots).toHaveLength(4);
expect(roots.every((root) => root.scope === 'user')).toBe(true);
expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents']);
expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents', 'codex']);
});
it('returns project and user roots when project path is provided', () => {
@ -18,8 +18,8 @@ describe('SkillRootsResolver', () => {
const roots = resolver.resolve('/tmp/demo-project');
expect(roots).toHaveLength(6);
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3);
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3);
expect(roots).toHaveLength(8);
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(4);
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(4);
});
});

View file

@ -40,6 +40,18 @@ describe('SkillValidator', () => {
expect(result[1].issues.map((issue) => issue.code)).toContain('duplicate-name');
});
it('does not warn when shared and codex-only overlays reuse the same skill name', () => {
const validator = new SkillValidator();
const result = validator.annotateCatalog([
makeSkill({ id: '/a', scope: 'project', rootKind: 'claude' }),
makeSkill({ id: '/b', scope: 'project', rootKind: 'codex' }),
]);
expect(result[0].issues.map((issue) => issue.code)).not.toContain('duplicate-name');
expect(result[1].issues.map((issue) => issue.code)).not.toContain('duplicate-name');
});
it('sorts by validity, scope, root precedence, then name', () => {
const validator = new SkillValidator();
@ -47,6 +59,7 @@ describe('SkillValidator', () => {
makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }),
makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }),
makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }),
makeSkill({ id: '/5', name: 'c-project-codex', scope: 'project', rootKind: 'codex' }),
makeSkill({
id: '/4',
name: 'invalid',
@ -55,6 +68,6 @@ describe('SkillValidator', () => {
}),
]);
expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/3', '/4']);
expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/5', '/3', '/4']);
});
});

View file

@ -41,7 +41,7 @@ describe('buildSlashCommandSuggestions', () => {
{
name: 'review-skill',
command: '/review-skill',
subtitle: 'Project skill',
subtitle: 'Project skill - Shared',
type: 'skill',
}
);
@ -76,6 +76,46 @@ describe('buildSlashCommandSuggestions', () => {
);
});
it('hides codex-only skills when the provider is not codex', () => {
const suggestions = buildSlashCommandSuggestions(
KNOWN_SLASH_COMMANDS,
[createSkill({ id: 'codex-project', folderName: 'codex-skill', rootKind: 'codex' })],
[],
'anthropic'
);
expect(suggestions.find((suggestion) => suggestion.id === 'skill:codex-project')).toBeUndefined();
});
it('prefers codex-only overlays ahead of shared skills for codex teams', () => {
const suggestions = buildSlashCommandSuggestions(
KNOWN_SLASH_COMMANDS,
[
createSkill({
id: 'shared-project',
folderName: 'review-skill',
rootKind: 'claude',
scope: 'project',
}),
createSkill({
id: 'codex-project',
folderName: 'review-skill',
rootKind: 'codex',
scope: 'project',
}),
],
[],
'codex'
);
expect(suggestions.filter((suggestion) => suggestion.command === '/review-skill')).toHaveLength(
1
);
expect(suggestions.find((suggestion) => suggestion.command === '/review-skill')?.id).toBe(
'skill:codex-project'
);
});
it('uses the provided built-in set when filtering skill collisions', () => {
const suggestions = buildSlashCommandSuggestions(
[