| 1 | import { |
| 2 | isProviderWorkspaceSnapshotCurrent, |
| 3 | type ServerProvider, |
| 4 | type ServerProviderSkill, |
| 5 | type ServerProviderSlashCommand, |
| 6 | } from "@t3tools/contracts"; |
| 7 | |
| 8 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; |
| 9 | |
| 10 | function normalizePathSeparators(pathValue: string): string { |
| 11 | return pathValue.replaceAll("\\", "/"); |
| 12 | } |
| 13 | |
| 14 | export function dedupeProviderSkillsByName( |
| 15 | skills: ReadonlyArray<ServerProviderSkill>, |
| 16 | ): ServerProviderSkill[] { |
| 17 | const seenNames = new Set<string>(); |
| 18 | return skills.filter((skill) => { |
| 19 | const normalizedName = skill.name.trim().toLowerCase(); |
| 20 | if (seenNames.has(normalizedName)) { |
| 21 | return false; |
| 22 | } |
| 23 | seenNames.add(normalizedName); |
| 24 | return true; |
| 25 | }); |
| 26 | } |
| 27 | |
| 28 | /** |
| 29 | * Whether a composer pick can start this skill. A skill switched off in the |
| 30 | * provider's settings will not run, and one the provider reserves for the |
| 31 | * agent (Claude Code's `user-invocable: false`) rejects a user invocation. |
| 32 | * Everything else, including skills the agent may not start on its own, is |
| 33 | * fair game: the server dispatches the pick in the provider's native form. |
| 34 | */ |
| 35 | export function isProviderSkillUserInvocable( |
| 36 | skill: Pick<ServerProviderSkill, "enabled" | "userInvocable">, |
| 37 | ): boolean { |
| 38 | return skill.enabled && skill.userInvocable !== false; |
| 39 | } |
| 40 | |
| 41 | export function getProviderSkillsForSlashMenu( |
| 42 | skills: ReadonlyArray<ServerProviderSkill>, |
| 43 | showSkillsInSlashMenu: boolean, |
| 44 | ): ServerProviderSkill[] { |
| 45 | return showSkillsInSlashMenu |
| 46 | ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)) |
| 47 | : []; |
| 48 | } |
| 49 | |
| 50 | export function getProviderSlashCommandsForSlashMenu( |
| 51 | slashCommands: ReadonlyArray<ServerProviderSlashCommand>, |
| 52 | visibleSkills: ReadonlyArray<ServerProviderSkill>, |
| 53 | ): ServerProviderSlashCommand[] { |
| 54 | const skillNames = new Set(visibleSkills.map((skill) => skill.name.trim().toLowerCase())); |
| 55 | return slashCommands.filter((command) => !skillNames.has(command.name.trim().toLowerCase())); |
| 56 | } |
| 57 | |
| 58 | export function resolveProviderSkillSourceKind( |
| 59 | skill: Pick<ServerProviderSkill, "path" | "scope">, |
| 60 | ): ProviderSkillSourceKind { |
| 61 | const normalizedPath = normalizePathSeparators(skill.path); |
| 62 | if (normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/")) { |
| 63 | return "app"; |
| 64 | } |
| 65 | |
| 66 | const normalizedScope = skill.scope?.trim().toLowerCase(); |
| 67 | switch (normalizedScope) { |
| 68 | case "repo": |
| 69 | case "repository": |
| 70 | return "repo"; |
| 71 | case "project": |
| 72 | case "workspace": |
| 73 | case "local": |
| 74 | return "project"; |
| 75 | case "user": |
| 76 | case "personal": |
| 77 | return "personal"; |
| 78 | case "system": |
| 79 | return "system"; |
| 80 | case undefined: |
| 81 | case "": |
| 82 | return "other"; |
| 83 | default: |
| 84 | return "other"; |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | function resolveProviderWorkspaceSnapshot( |
| 89 | provider: ServerProvider, |
| 90 | cwd: string | null | undefined, |
| 91 | ) { |
| 92 | if (!cwd) return undefined; |
| 93 | return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); |
| 94 | } |
| 95 | |
| 96 | export function hasCompleteProviderWorkspaceSnapshot( |
| 97 | provider: ServerProvider | null | undefined, |
| 98 | cwd: string | null | undefined, |
| 99 | ): boolean { |
| 100 | const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); |
| 101 | return Boolean(snapshot && !snapshot.slashCommandsPending); |
| 102 | } |
| 103 | |
| 104 | /** A complete snapshot young enough that opening a composer need not rescan. */ |
| 105 | export function hasCurrentProviderWorkspaceSnapshot( |
| 106 | provider: ServerProvider | null | undefined, |
| 107 | cwd: string | null | undefined, |
| 108 | nowMs: number, |
| 109 | ): boolean { |
| 110 | const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); |
| 111 | return Boolean( |
| 112 | snapshot && |
| 113 | !snapshot.slashCommandsPending && |
| 114 | isProviderWorkspaceSnapshotCurrent(snapshot, nowMs), |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | export function resolveProviderSkillsForCwd( |
| 119 | provider: ServerProvider, |
| 120 | cwd: string | null | undefined, |
| 121 | ): ServerProvider["skills"] { |
| 122 | return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills; |
| 123 | } |
| 124 | |
| 125 | export function resolveProviderSlashCommandsForCwd( |
| 126 | provider: ServerProvider, |
| 127 | cwd: string | null | undefined, |
| 128 | ): ServerProvider["slashCommands"] { |
| 129 | return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; |
| 130 | } |