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

View file

@ -1,9 +1,12 @@
import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots';
import type { SkillCatalogItem } from '@shared/types/extensions'; import type { SkillCatalogItem } from '@shared/types/extensions';
const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = { const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
claude: 0, claude: 0,
cursor: 1, cursor: 1,
agents: 2, agents: 2,
codex: 3,
}; };
export class SkillValidator { export class SkillValidator {
@ -21,14 +24,14 @@ export class SkillValidator {
private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] { private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] {
const itemsByName = new Map<string, SkillCatalogItem[]>(); const itemsByName = new Map<string, SkillCatalogItem[]>();
for (const item of items) { 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) ?? []; const bucket = itemsByName.get(key) ?? [];
bucket.push(item); bucket.push(item);
itemsByName.set(key, bucket); itemsByName.set(key, bucket);
} }
return items.map((item) => { return items.map((item) => {
const key = item.name.trim().toLowerCase(); const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
const duplicates = itemsByName.get(key) ?? []; const duplicates = itemsByName.get(key) ?? [];
if (duplicates.length <= 1) { if (duplicates.length <= 1) {
return item; return item;
@ -59,6 +62,7 @@ export class SkillValidator {
} }
private formatRootLabel(item: SkillCatalogItem): string { 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, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatSkillRootKind, getSkillAudienceLabel } from '@shared/utils/skillRoots';
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -81,18 +82,14 @@ export const SkillDetailDialog = ({
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot) ? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
: (projectPath ?? undefined); : (projectPath ?? undefined);
function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string {
return `.${rootKind}`;
}
function formatScopeLabel(scope: 'user' | 'project'): string { function formatScopeLabel(scope: 'user' | 'project'): string {
return scope === 'project' ? 'This project only' : 'Your personal skills'; return scope === 'project' ? 'This project only' : 'Your personal skills';
} }
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string { function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
return invocationMode === 'manual-only' return invocationMode === 'manual-only'
? 'Claude will only use this when you explicitly ask for it.' ? 'Only runs when you explicitly ask for it.'
: 'Claude can pick this automatically when it matches the task.'; : 'Runs automatically when it matches the task.';
} }
async function handleDelete(): Promise<void> { async function handleDelete(): Promise<void> {
@ -159,7 +156,8 @@ export const SkillDetailDialog = ({
)} )}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatScopeLabel(item.scope)}</Badge> <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"> <Badge variant="secondary">
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'} {item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
</Badge> </Badge>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@
export type SkillScope = 'user' | 'project'; export type SkillScope = 'user' | 'project';
export type SkillRootKind = 'claude' | 'cursor' | 'agents'; export type SkillRootKind = 'claude' | 'cursor' | 'agents' | 'codex';
export type SkillSourceType = 'filesystem'; 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(); const roots = resolver.resolve();
expect(roots).toHaveLength(3); expect(roots).toHaveLength(4);
expect(roots.every((root) => root.scope === 'user')).toBe(true); 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', () => { it('returns project and user roots when project path is provided', () => {
@ -18,8 +18,8 @@ describe('SkillRootsResolver', () => {
const roots = resolver.resolve('/tmp/demo-project'); const roots = resolver.resolve('/tmp/demo-project');
expect(roots).toHaveLength(6); expect(roots).toHaveLength(8);
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3); expect(roots.filter((root) => root.scope === 'project')).toHaveLength(4);
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3); 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'); 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', () => { it('sorts by validity, scope, root precedence, then name', () => {
const validator = new SkillValidator(); const validator = new SkillValidator();
@ -47,6 +59,7 @@ describe('SkillValidator', () => {
makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }), makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }),
makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }), makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }),
makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }), makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }),
makeSkill({ id: '/5', name: 'c-project-codex', scope: 'project', rootKind: 'codex' }),
makeSkill({ makeSkill({
id: '/4', id: '/4',
name: 'invalid', 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', name: 'review-skill',
command: '/review-skill', command: '/review-skill',
subtitle: 'Project skill', subtitle: 'Project skill - Shared',
type: 'skill', 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', () => { it('uses the provided built-in set when filtering skill collisions', () => {
const suggestions = buildSlashCommandSuggestions( const suggestions = buildSlashCommandSuggestions(
[ [