diff --git a/src/main/services/extensions/skills/SkillRootsResolver.ts b/src/main/services/extensions/skills/SkillRootsResolver.ts index 288b54e5..fcadf4c5 100644 --- a/src/main/services/extensions/skills/SkillRootsResolver.ts +++ b/src/main/services/extensions/skills/SkillRootsResolver.ts @@ -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[] { diff --git a/src/main/services/extensions/skills/SkillValidator.ts b/src/main/services/extensions/skills/SkillValidator.ts index a68dfb79..4de32e57 100644 --- a/src/main/services/extensions/skills/SkillValidator.ts +++ b/src/main/services/extensions/skills/SkillValidator.ts @@ -1,9 +1,12 @@ +import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots'; + import type { SkillCatalogItem } from '@shared/types/extensions'; const ROOT_PRECEDENCE: Record = { 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(); 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; } } diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index 2b57a9ca..2e2ec940 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -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 { @@ -159,7 +156,8 @@ export const SkillDetailDialog = ({ )}
{formatScopeLabel(item.scope)} - Stored in {formatRootKind(item.rootKind)} + Stored in {formatSkillRootKind(item.rootKind)} + {getSkillAudienceLabel(item.rootKind)} {item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'} diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index f5e625bf..6375f148 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -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('claude'); const [folderName, setFolderName] = useState(''); const [name, setName] = useState(''); const [description, setDescription] = useState(''); @@ -427,18 +429,19 @@ export const SkillEditorDialog = ({
diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index b49bdc2e..942cbdd9 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -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('claude'); const [preview, setPreview] = useState(null); const [reviewOpen, setReviewOpen] = useState(false); const [reviewLoading, setReviewLoading] = useState(false); @@ -273,17 +274,18 @@ export const SkillImportDialog = ({ diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 07262337..474c08d4 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -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 = ({
{cliStatus?.flavor === 'agent_teams_orchestrator' && (
- 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.
)}
@@ -249,7 +266,8 @@ export const SkillsPanel = ({

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.

@@ -327,6 +345,12 @@ export const SkillsPanel = ({ {userSkills.length} personal + + {sharedSkillsCount} shared + + + {codexOnlySkillsCount} Codex only +
@@ -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 = ({
- Stored in {formatRootKind(skill.rootKind)} + Stored in {formatSkillRootKind(skill.rootKind)} + + + {getSkillAudienceLabel(skill.rootKind)} {skill.flags.hasScripts && ( @@ -532,7 +561,10 @@ export const SkillsPanel = ({
- Stored in {formatRootKind(skill.rootKind)} + Stored in {formatSkillRootKind(skill.rootKind)} + + + {getSkillAudienceLabel(skill.rootKind)} {skill.flags.hasScripts && ( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index eff3ceea..c461aa94 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -237,7 +237,8 @@ export const MessageComposer = ({ buildSlashCommandSuggestions( getSuggestedSlashCommandsForProvider(leadProviderId), projectSkills, - userSkills + userSkills, + leadProviderId ), [leadProviderId, projectSkills, userSkills] ); diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts index 370df4e9..63bc0b43 100644 --- a/src/renderer/utils/skillCommandSuggestions.ts +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -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(); 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', }); diff --git a/src/shared/types/extensions/skill.ts b/src/shared/types/extensions/skill.ts index 5921acc7..9aca8729 100644 --- a/src/shared/types/extensions/skill.ts +++ b/src/shared/types/extensions/skill.ts @@ -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'; diff --git a/src/shared/utils/skillRoots.ts b/src/shared/utils/skillRoots.ts new file mode 100644 index 00000000..e2f84a42 --- /dev/null +++ b/src/shared/utils/skillRoots.ts @@ -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'; +} diff --git a/test/main/services/extensions/SkillRootsResolver.test.ts b/test/main/services/extensions/SkillRootsResolver.test.ts index 2c87294a..db7f60ca 100644 --- a/test/main/services/extensions/SkillRootsResolver.test.ts +++ b/test/main/services/extensions/SkillRootsResolver.test.ts @@ -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); }); }); diff --git a/test/main/services/extensions/SkillValidator.test.ts b/test/main/services/extensions/SkillValidator.test.ts index 513f6959..0fc22594 100644 --- a/test/main/services/extensions/SkillValidator.test.ts +++ b/test/main/services/extensions/SkillValidator.test.ts @@ -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']); }); }); diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts index 528a2f2d..43384c6e 100644 --- a/test/renderer/utils/skillCommandSuggestions.test.ts +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -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( [