1import {
2 isProviderWorkspaceSnapshotCurrent,
3 type ServerProvider,
4 type ServerProviderSkill,
5 type ServerProviderSlashCommand,
6} from "@t3tools/contracts";
7
8export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
9
10function normalizePathSeparators(pathValue: string): string {
11 return pathValue.replaceAll("\\", "/");
12}
13
14export 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 */
35export function isProviderSkillUserInvocable(
36 skill: Pick<ServerProviderSkill, "enabled" | "userInvocable">,
37): boolean {
38 return skill.enabled && skill.userInvocable !== false;
39}
40
41export function getProviderSkillsForSlashMenu(
42 skills: ReadonlyArray<ServerProviderSkill>,
43 showSkillsInSlashMenu: boolean,
44): ServerProviderSkill[] {
45 return showSkillsInSlashMenu
46 ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable))
47 : [];
48}
49
50export 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
58export 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
88function 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
96export 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. */
105export 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
118export function resolveProviderSkillsForCwd(
119 provider: ServerProvider,
120 cwd: string | null | undefined,
121): ServerProvider["skills"] {
122 return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills;
123}
124
125export function resolveProviderSlashCommandsForCwd(
126 provider: ServerProvider,
127 cwd: string | null | undefined,
128): ServerProvider["slashCommands"] {
129 return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands;
130}