feat(extensions): add codex-only skills overlays
This commit is contained in:
parent
f7c1dc16c4
commit
e01858ac98
13 changed files with 237 additions and 53 deletions
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -237,7 +237,8 @@ export const MessageComposer = ({
|
|||
buildSlashCommandSuggestions(
|
||||
getSuggestedSlashCommandsForProvider(leadProviderId),
|
||||
projectSkills,
|
||||
userSkills
|
||||
userSkills,
|
||||
leadProviderId
|
||||
),
|
||||
[leadProviderId, projectSkills, userSkills]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
61
src/shared/utils/skillRoots.ts
Normal file
61
src/shared/utils/skillRoots.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in a new issue