authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 20:07:31-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 20:07:31-04:00
log15fea6c5f40331c8325f33bd08b2dcbf924e935a
treeabd1d8747dfcd2404ee16bc0c6ec580113b261b7
parent443b4ebfe83fcfe64c34b09ecb5a5fffdebb85c7
signature Signed by PGP key B5690EEEBB952194

fix(providers): discover workspace skills everywhere (#9180)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

15 files changed, 698 insertions(+), 61 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1-1
...@@ -1053,7 +1053,7 @@ export function NewTaskDraftScreen(props: {...@@ -1053,7 +1053,7 @@ export function NewTaskDraftScreen(props: {
1053 multiline1053 multiline
1054 scrollEnabled1054 scrollEnabled
1055 value={flow.prompt}1055 value={flow.prompt}
1056 skills={flow.selectedProviderStatus?.skills ?? []}1056 skills={composerMenu.skills}
1057 selection={composerMenu.selection}1057 selection={composerMenu.selection}
1058 onChangeText={flow.setPrompt}1058 onChangeText={flow.setPrompt}
1059 onSelectionChange={composerMenu.onSelectionChange}1059 onSelectionChange={composerMenu.onSelectionChange}
apps/mobile/src/features/threads/ThreadComposer.tsx+1-1
...@@ -652,7 +652,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -652,7 +652,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
652 multiline652 multiline
653 value={props.draftMessage}653 value={props.draftMessage}
654 readOnly={voiceInput.freezesEditor}654 readOnly={voiceInput.freezesEditor}
655 skills={selectedProviderStatus?.skills ?? []}655 skills={composerMenu.skills}
656 selection={composerMenu.selection}656 selection={composerMenu.selection}
657 onChangeText={props.onChangeDraftMessage}657 onChangeText={props.onChangeDraftMessage}
658 onSelectionChange={composerMenu.onSelectionChange}658 onSelectionChange={composerMenu.onSelectionChange}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+10-7
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5} from "@t3tools/client-runtime/codex-artifact-templates";5} from "@t3tools/client-runtime/codex-artifact-templates";
6import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";6import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
7import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";7import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
8import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
8import type { LegendListRef } from "@legendapp/list/react-native";9import type { LegendListRef } from "@legendapp/list/react-native";
9import { HeaderHeightContext } from "@react-navigation/elements";10import { HeaderHeightContext } from "@react-navigation/elements";
10import type {11import type {
...@@ -510,12 +511,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -510,12 +511,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
510 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;511 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
511 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;512 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
512 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);513 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
513 const selectedProviderSkills = useMemo(514 const selectedProviderSkills = useMemo(() => {
514 () =>515 const provider = props.serverConfig?.providers.find(
515 props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)516 (candidate) => candidate.instanceId === selectedInstanceId,
516 ?.skills ?? [],517 );
517 [props.serverConfig, selectedInstanceId],518 return provider
518 );519 ? resolveProviderSkillsForCwd(provider, props.threadCwd ?? props.projectWorkspaceRoot)
520 : [];
521 }, [props.projectWorkspaceRoot, props.serverConfig, props.threadCwd, selectedInstanceId]);
519522
520 useLayoutEffect(() => {523 useLayoutEffect(() => {
521 selectedThreadKeyRef.current = selectedThreadKey;524 selectedThreadKeyRef.current = selectedThreadKey;
...@@ -809,7 +812,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -809,7 +812,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
809 serverConfig={props.serverConfig}812 serverConfig={props.serverConfig}
810 queueCount={props.selectedThreadQueueCount}813 queueCount={props.selectedThreadQueueCount}
811 environmentId={props.environmentId}814 environmentId={props.environmentId}
812 projectCwd={props.projectWorkspaceRoot}815 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
813 bottomInset={composerBottomInset}816 bottomInset={composerBottomInset}
814 onChangeDraftMessage={props.onChangeDraftMessage}817 onChangeDraftMessage={props.onChangeDraftMessage}
815 onPickDraftMedia={props.onPickDraftMedia}818 onPickDraftMedia={props.onPickDraftMedia}
apps/mobile/src/features/threads/use-composer-command-menu.test.ts+6
...@@ -3,6 +3,12 @@ import { describe, expect, it, vi } from "vite-plus/test";...@@ -3,6 +3,12 @@ import { describe, expect, it, vi } from "vite-plus/test";
3vi.mock("../../state/queries", () => ({3vi.mock("../../state/queries", () => ({
4 useComposerPathSearch: () => ({ entries: [], isPending: false }),4 useComposerPathSearch: () => ({ entries: [], isPending: false }),
5}));5}));
6vi.mock("../../state/server", () => ({
7 serverEnvironment: { refreshProviders: Symbol("refreshProviders") },
8}));
9vi.mock("../../state/use-atom-command", () => ({
10 useAtomCommand: () => vi.fn(),
11}));
612
7import { composerSelectionAtEnd } from "./use-composer-command-menu";13import { composerSelectionAtEnd } from "./use-composer-command-menu";
814
apps/mobile/src/features/threads/use-composer-command-menu.ts+84-7
...@@ -13,14 +13,19 @@ import {...@@ -13,14 +13,19 @@ import {
13 dedupeProviderSkillsByName,13 dedupeProviderSkillsByName,
14 getProviderSkillsForSlashMenu,14 getProviderSkillsForSlashMenu,
15 isProviderSkillUserInvocable,15 isProviderSkillUserInvocable,
16 resolveProviderSkillsForCwd,
16} from "@t3tools/client-runtime/providerSkills";17} from "@t3tools/client-runtime/providerSkills";
17import { useCallback, useEffect, useMemo, useRef, useState } from "react";18import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1819
19import type { ComposerEditorSelection } from "../../components/ComposerEditor";20import type { ComposerEditorSelection } from "../../components/ComposerEditor";
21import { serverEnvironment } from "../../state/server";
22import { useAtomCommand } from "../../state/use-atom-command";
20import { useComposerPathSearch } from "../../state/queries";23import { useComposerPathSearch } from "../../state/queries";
21import type { ComposerCommandItem } from "./ComposerCommandPopover";24import type { ComposerCommandItem } from "./ComposerCommandPopover";
22import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";25import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
2326
27const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;
28
24export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection {29export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection {
25 return { start: draftMessage.length, end: draftMessage.length };30 return { start: draftMessage.length, end: draftMessage.length };
26}31}
...@@ -69,6 +74,72 @@ export function useComposerCommandMenu({...@@ -69,6 +74,72 @@ export function useComposerCommandMenu({
69 setSelection(composerSelectionAtEnd(draftMessage));74 setSelection(composerSelectionAtEnd(draftMessage));
70 }, [draftMessage, ownerKey]);75 }, [draftMessage, ownerKey]);
7176
77 const skills = useMemo(
78 () =>
79 selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [],
80 [projectCwd, selectedProviderStatus],
81 );
82 const slashCommands = selectedProviderStatus?.slashCommands ?? [];
83 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
84 reportFailure: false,
85 });
86 const selectedProviderInstanceId = selectedProviderStatus?.instanceId;
87 const hasWorkspaceSnapshot = Boolean(
88 projectCwd &&
89 selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd),
90 );
91 const workspaceRefreshKeyRef = useRef<string | null>(null);
92 const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null);
93 const hadWorkspaceSnapshotRef = useRef(false);
94 useEffect(() => {
95 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
96 workspaceRefreshKeyRef.current = null;
97 workspaceRefreshRetryRef.current = null;
98 }
99 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
100 }, [hasWorkspaceSnapshot]);
101 useEffect(() => {
102 if (!environmentId || !projectCwd || !selectedProviderInstanceId) return;
103 const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`;
104 if (workspaceRefreshKeyRef.current === key) return;
105 if (hasWorkspaceSnapshot) {
106 workspaceRefreshKeyRef.current = key;
107 workspaceRefreshRetryRef.current = null;
108 return;
109 }
110 const retry = workspaceRefreshRetryRef.current;
111 if (retry?.key === key && Date.now() < retry.notBefore) return;
112 workspaceRefreshKeyRef.current = key;
113 const retryLater = () => {
114 if (workspaceRefreshKeyRef.current !== key) return;
115 workspaceRefreshKeyRef.current = null;
116 workspaceRefreshRetryRef.current = {
117 key,
118 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
119 };
120 };
121 void refreshProviders({
122 environmentId,
123 input: { instanceId: selectedProviderInstanceId, cwd: projectCwd },
124 }).then((result) => {
125 const refreshed =
126 result._tag === "Success" &&
127 result.value.providers
128 .find((provider) => provider.instanceId === selectedProviderInstanceId)
129 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd);
130 if (!refreshed && workspaceRefreshKeyRef.current === key) {
131 retryLater();
132 }
133 }, retryLater);
134 }, [
135 draftMessage,
136 environmentId,
137 hasWorkspaceSnapshot,
138 projectCwd,
139 refreshProviders,
140 selectedProviderInstanceId,
141 ]);
142
72 const trigger = useMemo(() => {143 const trigger = useMemo(() => {
73 if (!enabled || selection.start !== selection.end) {144 if (!enabled || selection.start !== selection.end) {
74 return null;145 return null;
...@@ -120,8 +191,7 @@ export function useComposerCommandMenu({...@@ -120,8 +191,7 @@ export function useComposerCommandMenu({
120 // locally and skills insert a `$` mention the server dispatches from191 // locally and skills insert a `$` mention the server dispatches from
121 // any position, so only provider commands are position-gated.192 // any position, so only provider commands are position-gated.
122 const providerCommands: ComposerCommandItem[] = [];193 const providerCommands: ComposerCommandItem[] = [];
123 const expandableCommands =194 const expandableCommands = trigger.rangeStart === 0 ? slashCommands : [];
124 trigger.rangeStart === 0 ? (selectedProviderStatus?.slashCommands ?? []) : [];
125 for (const command of expandableCommands) {195 for (const command of expandableCommands) {
126 if (!command.name.toLowerCase().includes(q)) continue;196 if (!command.name.toLowerCase().includes(q)) continue;
127 // Codex feedback uploads an existing thread's session and logs.197 // Codex feedback uploads an existing thread's session and logs.
...@@ -141,7 +211,7 @@ export function useComposerCommandMenu({...@@ -141,7 +211,7 @@ export function useComposerCommandMenu({
141 });211 });
142 }212 }
143213
144 const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true)214 const skillItems = getProviderSkillsForSlashMenu(skills, true)
145 .filter((skill) => matchesSlashSkillQuery(skill, q))215 .filter((skill) => matchesSlashSkillQuery(skill, q))
146 .map((skill) => ({216 .map((skill) => ({
147 id: `skill:${skill.name}`,217 id: `skill:${skill.name}`,
...@@ -155,9 +225,7 @@ export function useComposerCommandMenu({...@@ -155,9 +225,7 @@ export function useComposerCommandMenu({
155 }225 }
156226
157 if (trigger.kind === "skill") {227 if (trigger.kind === "skill") {
158 const enabledSkills = dedupeProviderSkillsByName(228 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
159 (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable),
160 );
161 const normalizedQuery = normalizeSearchQuery(trigger.query, {229 const normalizedQuery = normalizeSearchQuery(trigger.query, {
162 trimLeadingPattern: /^\$+/,230 trimLeadingPattern: /^\$+/,
163 });231 });
...@@ -254,7 +322,15 @@ export function useComposerCommandMenu({...@@ -254,7 +322,15 @@ export function useComposerCommandMenu({
254 }322 }
255323
256 return [];324 return [];
257 }, [hasThread, onUpdateInteractionMode, pathSearch.entries, selectedProviderStatus, trigger]);325 }, [
326 hasThread,
327 onUpdateInteractionMode,
328 pathSearch.entries,
329 selectedProviderStatus,
330 skills,
331 slashCommands,
332 trigger,
333 ]);
258334
259 const onSelect = useCallback(335 const onSelect = useCallback(
260 (item: ComposerCommandItem) => {336 (item: ComposerCommandItem) => {
...@@ -299,6 +375,7 @@ export function useComposerCommandMenu({...@@ -299,6 +375,7 @@ export function useComposerCommandMenu({
299 onSelectionChange,375 onSelectionChange,
300 trigger,376 trigger,
301 items,377 items,
378 skills,
302 isLoading: pathSearch.isPending,379 isLoading: pathSearch.isPending,
303 onSelect,380 onSelect,
304 };381 };
apps/server/src/provider/Drivers/CursorDriver.ts+20
...@@ -50,6 +50,7 @@ import {...@@ -50,6 +50,7 @@ import {
50 makeProviderSnapshotSettingsSource,50 makeProviderSnapshotSettingsSource,
51 type ProviderSnapshotSettings,51 type ProviderSnapshotSettings,
52} from "../providerUpdateSettings.ts";52} from "../providerUpdateSettings.ts";
53import { probeCursorSkills } from "./CursorSkills.ts";
53const decodeCursorSettings = Schema.decodeSync(CursorSettings);54const decodeCursorSettings = Schema.decodeSync(CursorSettings);
5455
55const DRIVER_KIND = ProviderDriverKind.make("cursor");56const DRIVER_KIND = ProviderDriverKind.make("cursor");
...@@ -166,6 +167,25 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -166,6 +167,25 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
166 accentColor,167 accentColor,
167 enabled,168 enabled,
168 snapshot,169 snapshot,
170 snapshotForCwd: (cwd) =>
171 !effectiveConfig.enabled
172 ? snapshot.getSnapshot
173 : Effect.all([
174 snapshot.getSnapshot,
175 probeCursorSkills(cwd, processEnv).pipe(
176 Effect.provideService(FileSystem.FileSystem, fileSystem),
177 Effect.provideService(Path.Path, path),
178 Effect.mapError(
179 (cause) =>
180 new ProviderDriverError({
181 driver: DRIVER_KIND,
182 instanceId,
183 detail: `Failed to discover Cursor skills for '${cwd}'`,
184 cause,
185 }),
186 ),
187 ),
188 ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))),
169 adapter,189 adapter,
170 textGeneration,190 textGeneration,
171 } satisfies ProviderInstance;191 } satisfies ProviderInstance;
apps/server/src/provider/Drivers/CursorSkills.ts created+283
...@@ -0,0 +1,283 @@
1/**
2 * CursorSkills — workspace-aware discovery and native invocation for Cursor.
3 *
4 * Cursor discovers Agent Skills recursively from user and project roots but
5 * its ACP command catalog only appears after opening a real session. Scanning
6 * the same roots avoids starting an agent and its MCP servers just to populate
7 * a composer menu.
8 *
9 * @module provider/Drivers/CursorSkills
10 */
11import * as NodeOS from "node:os";
12
13import type { ServerProviderSkill } from "@t3tools/contracts";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";
18import * as Schema from "effect/Schema";
19import { parse as parseYamlDocument } from "yaml";
20
21const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
22const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
23const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/;
24const MAX_SKILL_DEPTH = 10;
25const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);
26const MAX_SKILL_SCAN_ENTRIES = 10_000;
27const MAX_SKILL_SCAN_BYTES = FileSystem.Size(8_000_000);
28
29interface CursorSkillFrontmatter {
30 readonly description?: string;
31 readonly displayName?: string;
32 readonly userInvocationOnly?: boolean;
33 readonly userInvocable?: boolean;
34 readonly cliVisible: boolean;
35}
36
37interface CursorSkillScanBudget {
38 remainingEntries: number;
39 remainingBytes: bigint;
40 exhausted: boolean;
41 incomplete: boolean;
42}
43
44class CursorSkillsProbeError extends Schema.TaggedErrorClass<CursorSkillsProbeError>()(
45 "CursorSkillsProbeError",
46 {
47 reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]),
48 cwd: Schema.optional(Schema.String),
49 },
50) {
51 override get message(): string {
52 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
53 return `Cursor skill discovery${location} was incomplete (${this.reason}).`;
54 }
55}
56
57const orUndefined = <A, R>(
58 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
59 budget?: CursorSkillScanBudget,
60): Effect.Effect<A | undefined, never, R> =>
61 effect.pipe(
62 Effect.map((value): A | undefined => value),
63 Effect.catchTags({
64 PlatformError: (error) => {
65 if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true;
66 return Effect.void.pipe(Effect.as(undefined));
67 },
68 }),
69 );
70
71function parseFrontmatterBoolean(value: unknown): boolean | undefined {
72 if (typeof value === "boolean") return value;
73 if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined;
74 if (typeof value !== "string") return undefined;
75 switch (value.trim().toLowerCase()) {
76 case "true":
77 case "yes":
78 case "on":
79 return true;
80 case "false":
81 case "no":
82 case "off":
83 return false;
84 default:
85 return undefined;
86 }
87}
88
89function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined {
90 const match = FRONTMATTER_PATTERN.exec(contents);
91 if (!match) return { cliVisible: true };
92
93 let parsed: unknown;
94 try {
95 parsed = parseYamlDocument(match[1] ?? "");
96 } catch {
97 return undefined;
98 }
99 if (typeof parsed !== "object" || parsed === null) return undefined;
100
101 const record = parsed as Record<string, unknown>;
102 const metadata =
103 typeof record.metadata === "object" && record.metadata !== null
104 ? (record.metadata as Record<string, unknown>)
105 : undefined;
106 const rawSurfaces = metadata?.surfaces;
107 const surfaces = Array.isArray(rawSurfaces)
108 ? rawSurfaces.filter((surface): surface is string => typeof surface === "string")
109 : typeof rawSurfaces === "string"
110 ? rawSurfaces.split(",")
111 : [];
112 const description = typeof record.description === "string" ? record.description.trim() : "";
113 const displayName = typeof record.name === "string" ? record.name.trim() : "";
114 return {
115 cliVisible:
116 surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"),
117 ...(description ? { description } : {}),
118 ...(displayName ? { displayName } : {}),
119 ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
120 ? { userInvocationOnly: true }
121 : {}),
122 ...(parseFrontmatterBoolean(record["user-invocable"]) === false
123 ? { userInvocable: false }
124 : {}),
125 };
126}
127
128const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: {
129 readonly directory: string;
130 readonly scope: "user" | "project";
131 readonly budget: CursorSkillScanBudget;
132}): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
133 const fileSystem = yield* FileSystem.FileSystem;
134 const path = yield* Path.Path;
135 const skills: ServerProviderSkill[] = [];
136 if (input.budget.exhausted) return skills;
137 const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget);
138 if (!rootDirectory) return skills;
139 const visitedDirectories = new Set<string>();
140
141 const visit = Effect.fn("visitCursorSkillDirectory")(function* (
142 directory: string,
143 depth: number,
144 ): Effect.fn.Return<void, never> {
145 if (input.budget.exhausted) return;
146 const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget);
147 if (!resolvedDirectory) {
148 return;
149 }
150 if (
151 visitedDirectories.has(resolvedDirectory) ||
152 (resolvedDirectory !== rootDirectory &&
153 !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`))
154 ) {
155 return;
156 }
157 visitedDirectories.add(resolvedDirectory);
158
159 const skillPath = path.join(resolvedDirectory, "SKILL.md");
160 const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget);
161 if (skillInfo?.type === "File") {
162 let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true };
163 if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) {
164 const contents = yield* orUndefined(fileSystem.readFileString(skillPath));
165 if (contents !== undefined) {
166 input.budget.remainingBytes -= skillInfo.size;
167 frontmatter = parseSkillFrontmatter(contents);
168 }
169 }
170 const name = path.basename(resolvedDirectory).trim();
171 if (frontmatter?.cliVisible && name) {
172 skills.push({
173 name,
174 path: skillPath,
175 scope: input.scope,
176 enabled: true,
177 ...(frontmatter.displayName && frontmatter.displayName !== name
178 ? { displayName: frontmatter.displayName }
179 : {}),
180 ...(frontmatter.description ? { description: frontmatter.description } : {}),
181 ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}),
182 ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}),
183 });
184 }
185 }
186
187 const entries = yield* orUndefined(fileSystem.readDirectory(resolvedDirectory), input.budget);
188 if (!entries) {
189 return;
190 }
191 for (const entry of [...entries].sort()) {
192 if (input.budget.remainingEntries === 0) {
193 input.budget.exhausted = true;
194 return;
195 }
196 input.budget.remainingEntries -= 1;
197 const child = path.join(resolvedDirectory, entry);
198 const info = yield* orUndefined(fileSystem.stat(child), input.budget);
199 if (info?.type !== "Directory") continue;
200 if (depth >= MAX_SKILL_DEPTH) {
201 input.budget.exhausted = true;
202 return;
203 }
204 yield* visit(child, depth + 1);
205 }
206 });
207
208 yield* visit(rootDirectory, 0);
209 return skills;
210});
211
212const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* (
213 cwd?: string,
214 environment: NodeJS.ProcessEnv = process.env,
215) {
216 const path = yield* Path.Path;
217 const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir();
218 const rootsBelow = (base: string, scope: "user" | "project") => [
219 { directory: path.join(base, ".cursor", "skills"), scope },
220 { directory: path.join(base, ".agents", "skills"), scope },
221 { directory: path.join(base, ".codex", "skills"), scope },
222 { directory: path.join(base, ".claude", "skills"), scope },
223 ];
224 const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")];
225
226 const skillsByName = new Map<string, ServerProviderSkill>();
227 const budget: CursorSkillScanBudget = {
228 remainingEntries: MAX_SKILL_SCAN_ENTRIES,
229 remainingBytes: MAX_SKILL_SCAN_BYTES,
230 exhausted: false,
231 incomplete: false,
232 };
233 for (const root of roots) {
234 if (budget.exhausted) break;
235 const skills = yield* discoverSkillsInRoot({ ...root, budget });
236 for (const skill of skills) {
237 if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill);
238 }
239 }
240 return {
241 skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)),
242 failureReason: budget.exhausted
243 ? ("scan-budget-exhausted" as const)
244 : budget.incomplete
245 ? ("filesystem-error" as const)
246 : undefined,
247 };
248});
249
250export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* (
251 cwd?: string,
252 environment: NodeJS.ProcessEnv = process.env,
253) {
254 return (yield* inspectCursorSkills(cwd, environment)).skills;
255});
256
257export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* (
258 cwd?: string,
259 environment: NodeJS.ProcessEnv = process.env,
260) {
261 const inspection = yield* inspectCursorSkills(cwd, environment);
262 if (inspection.failureReason) {
263 return yield* new CursorSkillsProbeError({
264 reason: inspection.failureReason,
265 ...(cwd ? { cwd } : {}),
266 });
267 }
268 return inspection.skills;
269});
270
271/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */
272export function hasCursorSkillMention(prompt: string): boolean {
273 return HAS_SKILL_MENTION_PATTERN.test(prompt);
274}
275
276export function rewriteCursorSkillMentions(
277 prompt: string,
278 skillNames: ReadonlySet<string>,
279): string {
280 return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) =>
281 skillNames.has(name) ? `${prefix}/${name}` : match,
282 );
283}
apps/server/src/provider/Drivers/GrokDriver.ts+20
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27} from "../ProviderDriver.ts";27} from "../ProviderDriver.ts";
28import { withInstanceIdentity } from "./instanceIdentity.ts";28import { withInstanceIdentity } from "./instanceIdentity.ts";
29import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";29import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
30import { discoverGrokSkills } from "./GrokSkills.ts";
30import {31import {
31 makeManualOnlyProviderMaintenanceCapabilities,32 makeManualOnlyProviderMaintenanceCapabilities,
32 makeStaticProviderMaintenanceResolver,33 makeStaticProviderMaintenanceResolver,
...@@ -133,6 +134,24 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -133,6 +134,24 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
133 }),134 }),
134 ),135 ),
135 );136 );
137 const snapshotForCwd = (workspaceCwd: string) =>
138 !effectiveConfig.enabled
139 ? snapshot.getSnapshot
140 : Effect.all([
141 snapshot.getSnapshot,
142 discoverGrokSkills(effectiveConfig, processEnv, workspaceCwd).pipe(
143 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
144 Effect.mapError(
145 (cause) =>
146 new ProviderDriverError({
147 driver: DRIVER_KIND,
148 instanceId,
149 detail: `Failed to discover Grok skills for '${workspaceCwd}'`,
150 cause,
151 }),
152 ),
153 ),
154 ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })));
136155
137 return {156 return {
138 instanceId,157 instanceId,
...@@ -142,6 +161,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -142,6 +161,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
142 accentColor,161 accentColor,
143 enabled,162 enabled,
144 snapshot,163 snapshot,
164 snapshotForCwd,
145 adapter,165 adapter,
146 textGeneration,166 textGeneration,
147 } satisfies ProviderInstance;167 } satisfies ProviderInstance;
apps/server/src/provider/Drivers/GrokSkills.test.ts+10-2
...@@ -93,14 +93,15 @@ describe("parseGrokInspectSkills", () => {...@@ -93,14 +93,15 @@ describe("parseGrokInspectSkills", () => {
93});93});
9494
95describe("discoverGrokSkills", () => {95describe("discoverGrokSkills", () => {
96 it.effect("spawns the inspect probe in the configured cwd", () => {96 it.effect("spawns in the configured cwd and rejects a failed probe", () => {
97 const spawnCwds: Array<string | undefined> = [];97 const spawnCwds: Array<string | undefined> = [];
98 let exitCode = 0;
98 const spawner = ChildProcessSpawner.make((command) => {99 const spawner = ChildProcessSpawner.make((command) => {
99 spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined);100 spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined);
100 return Effect.succeed(101 return Effect.succeed(
101 ChildProcessSpawner.makeHandle({102 ChildProcessSpawner.makeHandle({
102 pid: ChildProcessSpawner.ProcessId(1),103 pid: ChildProcessSpawner.ProcessId(1),
103 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),104 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
104 isRunning: Effect.succeed(false),105 isRunning: Effect.succeed(false),
105 kill: () => Effect.void,106 kill: () => Effect.void,
106 unref: Effect.succeed(Effect.void),107 unref: Effect.succeed(Effect.void),
...@@ -130,6 +131,13 @@ describe("discoverGrokSkills", () => {...@@ -130,6 +131,13 @@ describe("discoverGrokSkills", () => {
130131
131 expect(spawnCwds).toEqual(["/workspaces/demo"]);132 expect(spawnCwds).toEqual(["/workspaces/demo"]);
132 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);133 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
134
135 exitCode = 1;
136 const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe(
137 Effect.result,
138 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
139 );
140 expect(failed._tag).toBe("Failure");
133 });141 });
134 });142 });
135});143});
apps/server/src/provider/Drivers/GrokSkills.ts+60-24
...@@ -9,41 +9,57 @@...@@ -9,41 +9,57 @@
9 * (ignore lists, disabled skills) and includes plugin skills, which live9 * (ignore lists, disabled skills) and includes plugin skills, which live
10 * three levels deep under `~/.grok/installed-plugins/` where a flat scan10 * three levels deep under `~/.grok/installed-plugins/` where a flat scan
11 * cannot see them. This mirrors how the Codex app-server reports skills over11 * cannot see them. This mirrors how the Codex app-server reports skills over
12 * `skills/list`. Discovery is best-effort: an older CLI without `inspect`,12 * `skills/list`. Probe failures stay typed so workspace snapshots do not
13 * a timeout, or malformed output yields an empty list, never a degraded13 * cache an empty catalog; machine-level discovery recovers them to an empty
14 * provider snapshot.14 * list without degrading the provider.
15 *15 *
16 * @module provider/Drivers/GrokSkills16 * @module provider/Drivers/GrokSkills
17 */17 */
18import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts";18import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts";
19import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";20import * as Option from "effect/Option";
21import * as Result from "effect/Result";21import * as Schema from "effect/Schema";
22import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";22import { ChildProcess } from "effect/unstable/process";
23import { resolveSpawnCommand } from "@t3tools/shared/shell";23import { resolveSpawnCommand } from "@t3tools/shared/shell";
2424
25import { spawnAndCollect } from "../providerSnapshot.ts";25import { spawnAndCollect } from "../providerSnapshot.ts";
2626
27const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;27const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;
2828
29class GrokSkillsProbeError extends Schema.TaggedErrorClass<GrokSkillsProbeError>()(
30 "GrokSkillsProbeError",
31 {
32 stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]),
33 cwd: Schema.optional(Schema.String),
34 exitCode: Schema.optional(Schema.Number),
35 cause: Schema.optional(Schema.Defect()),
36 },
37) {
38 override get message(): string {
39 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
40 const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;
41 return `\`grok inspect --json\` failed during ${this.stage}${location}${exitCode}.`;
42 }
43}
44
29/**45/**
30 * Map `grok inspect --json` output onto provider skills. Entries without a46 * Map `grok inspect --json` output onto provider skills. Entries without a
31 * name or a filesystem path are skipped; `userInvocable: false` skills are47 * name or a filesystem path are skipped; `userInvocable: false` skills are
32 * kept but disabled so pickers that filter on `enabled` hide them.48 * kept but disabled so pickers that filter on `enabled` hide them.
33 */49 */
34export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> {50function decodeGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> | undefined {
35 let parsed: unknown;51 let parsed: unknown;
36 try {52 try {
37 parsed = JSON.parse(stdout);53 parsed = JSON.parse(stdout);
38 } catch {54 } catch {
39 return [];55 return undefined;
40 }56 }
41 if (typeof parsed !== "object" || parsed === null) {57 if (typeof parsed !== "object" || parsed === null) {
42 return [];58 return undefined;
43 }59 }
44 const entries = (parsed as Record<string, unknown>).skills;60 const entries = (parsed as Record<string, unknown>).skills;
45 if (!Array.isArray(entries)) {61 if (!Array.isArray(entries)) {
46 return [];62 return undefined;
47 }63 }
4864
49 const skillsByName = new Map<string, ServerProviderSkill>();65 const skillsByName = new Map<string, ServerProviderSkill>();
...@@ -75,20 +91,20 @@ export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProv...@@ -75,20 +91,20 @@ export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProv
75 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));91 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
76}92}
7793
94export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> {
95 return decodeGrokInspectSkills(stdout) ?? [];
96}
97
78/**98/**
79 * Run `grok inspect --json` and map the reported catalog onto provider99 * Run `grok inspect --json` and map the reported catalog onto provider
80 * skills. Never fails: any spawn error, non-zero exit, or timeout resolves100 * skills. Callers that need best-effort discovery can recover this effect to
81 * to an empty list.101 * an empty list; workspace callers leave failures typed so they are not cached.
82 */102 */
83export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (103export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (
84 grokSettings: Pick<GrokSettings, "binaryPath">,104 grokSettings: Pick<GrokSettings, "binaryPath">,
85 environment: NodeJS.ProcessEnv = process.env,105 environment: NodeJS.ProcessEnv = process.env,
86 cwd?: string,106 cwd?: string,
87): Effect.fn.Return<107) {
88 ReadonlyArray<ServerProviderSkill>,
89 never,
90 ChildProcessSpawner.ChildProcessSpawner
91> {
92 const command = grokSettings.binaryPath || "grok";108 const command = grokSettings.binaryPath || "grok";
93 const inspectResult = yield* Effect.gen(function* () {109 const inspectResult = yield* Effect.gen(function* () {
94 const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], {110 const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], {
...@@ -102,18 +118,38 @@ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (...@@ -102,18 +118,38 @@ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (
102 shell: spawnCommand.shell,118 shell: spawnCommand.shell,
103 }),119 }),
104 );120 );
105 }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result);121 }).pipe(
122 Effect.mapError(
123 (cause) =>
124 new GrokSkillsProbeError({
125 stage: "spawn",
126 ...(cwd ? { cwd } : {}),
127 cause,
128 }),
129 ),
130 Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS),
131 );
106132
107 if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) {133 if (Option.isNone(inspectResult)) {
108 yield* Effect.logDebug("Grok skill discovery failed; continuing without skills.");134 return yield* new GrokSkillsProbeError({
109 return [];135 stage: "timeout",
136 ...(cwd ? { cwd } : {}),
137 });
110 }138 }
111 const output = inspectResult.success.value;139 const output = inspectResult.value;
112 if (output.code !== 0) {140 if (output.code !== 0) {
113 yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", {141 return yield* new GrokSkillsProbeError({
142 stage: "exit",
143 ...(cwd ? { cwd } : {}),
114 exitCode: output.code,144 exitCode: output.code,
115 });145 });
116 return [];
117 }146 }
118 return parseGrokInspectSkills(output.stdout);147 const skills = decodeGrokInspectSkills(output.stdout);
148 if (!skills) {
149 return yield* new GrokSkillsProbeError({
150 stage: "decode",
151 ...(cwd ? { cwd } : {}),
152 });
153 }
154 return skills;
119});155});
apps/server/src/provider/Layers/CursorAdapter.test.ts+46
...@@ -250,6 +250,52 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -250,6 +250,52 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
250 }),250 }),
251 );251 );
252252
253 it.effect("sends selected project skills in Cursor's native slash form", () =>
254 Effect.gen(function* () {
255 const adapter = yield* CursorAdapter;
256 const settings = yield* ServerSettingsService;
257 const threadId = ThreadId.make("cursor-skill-dispatch");
258 const workspace = yield* Effect.promise(() =>
259 NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-dispatch-")),
260 );
261 const requestLogPath = NodePath.join(workspace, "requests.ndjson");
262 const argvLogPath = NodePath.join(workspace, "argv.txt");
263 const skillDirectory = NodePath.join(workspace, ".cursor", "skills", "review");
264 yield* Effect.promise(() => NodeFSP.mkdir(skillDirectory, { recursive: true }));
265 yield* Effect.promise(() =>
266 NodeFSP.writeFile(NodePath.join(skillDirectory, "SKILL.md"), "# Review\n", "utf8"),
267 );
268 yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8"));
269 const wrapperPath = yield* Effect.promise(() =>
270 makeProbeWrapper(requestLogPath, argvLogPath),
271 );
272 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
273
274 yield* adapter.startSession({
275 threadId,
276 provider: ProviderDriverKind.make("cursor"),
277 cwd: workspace,
278 runtimeMode: "full-access",
279 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
280 });
281 yield* adapter.sendTurn({
282 threadId,
283 input: "please $review this",
284 attachments: [],
285 });
286 yield* adapter.stopSession(threadId);
287
288 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
289 const promptRequests = requests.filter((entry) => entry.method === "session/prompt");
290 assert.deepStrictEqual(
291 promptRequests.map(
292 (request) => (request.params as Record<string, unknown> | undefined)?.prompt,
293 ),
294 [[{ type: "text", text: "please /review this" }]],
295 );
296 }),
297 );
298
253 it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () =>299 it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () =>
254 Effect.gen(function* () {300 Effect.gen(function* () {
255 const adapter = yield* CursorAdapter;301 const adapter = yield* CursorAdapter;
apps/server/src/provider/Layers/CursorAdapter.ts+29-2
...@@ -77,6 +77,11 @@ import {...@@ -77,6 +77,11 @@ import {
77import { type CursorAdapterShape } from "../Services/CursorAdapter.ts";77import { type CursorAdapterShape } from "../Services/CursorAdapter.ts";
78import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";78import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
79import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";79import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
80import {
81 discoverCursorSkills,
82 hasCursorSkillMention,
83 rewriteCursorSkillMentions,
84} from "../Drivers/CursorSkills.ts";
80const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));85const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
8186
82const PROVIDER = ProviderDriverKind.make("cursor");87const PROVIDER = ProviderDriverKind.make("cursor");
...@@ -133,6 +138,7 @@ interface CursorSessionContext {...@@ -133,6 +138,7 @@ interface CursorSessionContext {
133 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;138 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
134 lastPlanFingerprint: string | undefined;139 lastPlanFingerprint: string | undefined;
135 activeTurnId: TurnId | undefined;140 activeTurnId: TurnId | undefined;
141 cursorSkillNames: ReadonlySet<string> | undefined;
136 /** Number of sendTurn prompts currently in flight or being prepared.142 /** Number of sendTurn prompts currently in flight or being prepared.
137 * >0 means a turn is actively running, so a new sendTurn is a steer that143 * >0 means a turn is actively running, so a new sendTurn is a steer that
138 * continues it, and only the last remaining prompt settles the turn. */144 * continues it, and only the last remaining prompt settles the turn. */
...@@ -779,6 +785,7 @@ export function makeCursorAdapter(...@@ -779,6 +785,7 @@ export function makeCursorAdapter(
779 turns: [],785 turns: [],
780 lastPlanFingerprint: undefined,786 lastPlanFingerprint: undefined,
781 activeTurnId: undefined,787 activeTurnId: undefined,
788 cursorSkillNames: undefined,
782 promptsInFlight: 0,789 promptsInFlight: 0,
783 stopped: false,790 stopped: false,
784 };791 };
...@@ -968,8 +975,28 @@ export function makeCursorAdapter(...@@ -968,8 +975,28 @@ export function makeCursorAdapter(
968 }975 }
969976
970 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];977 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
971 if (input.input?.trim()) {978 const rawPrompt = input.input?.trim() ?? "";
972 promptParts.push({ type: "text", text: input.input.trim() });979 if (rawPrompt) {
980 let cursorSkillNames = ctx.cursorSkillNames;
981 if (hasCursorSkillMention(rawPrompt) && cursorSkillNames === undefined) {
982 const skills = yield* discoverCursorSkills(
983 ctx.session.cwd,
984 options?.environment,
985 ).pipe(
986 Effect.provideService(FileSystem.FileSystem, fileSystem),
987 Effect.provideService(Path.Path, path),
988 );
989 cursorSkillNames = new Set(
990 skills
991 .filter((skill) => skill.enabled && skill.userInvocable !== false)
992 .map((skill) => skill.name),
993 );
994 ctx.cursorSkillNames = cursorSkillNames;
995 }
996 const prompt = cursorSkillNames
997 ? rewriteCursorSkillMentions(rawPrompt, cursorSkillNames)
998 : rawPrompt;
999 promptParts.push({ type: "text", text: prompt });
973 }1000 }
974 if (input.attachments && input.attachments.length > 0) {1001 if (input.attachments && input.attachments.length > 0) {
975 for (const attachment of input.attachments) {1002 for (const attachment of input.attachments) {
apps/server/src/provider/Layers/CursorProvider.test.ts+98
...@@ -24,6 +24,12 @@ import {...@@ -24,6 +24,12 @@ import {
24 resolveCursorAcpBaseModelId,24 resolveCursorAcpBaseModelId,
25 resolveCursorAcpConfigUpdates,25 resolveCursorAcpConfigUpdates,
26} from "./CursorProvider.ts";26} from "./CursorProvider.ts";
27import {
28 discoverCursorSkills,
29 hasCursorSkillMention,
30 probeCursorSkills,
31 rewriteCursorSkillMentions,
32} from "../Drivers/CursorSkills.ts";
2733
28const runNode = <A, E>(34const runNode = <A, E>(
29 effect: Effect.Effect<35 effect: Effect.Effect<
...@@ -312,6 +318,98 @@ const cursorCliCommandMissingMessage = [...@@ -312,6 +318,98 @@ const cursorCliCommandMissingMessage = [
312 "See https://cursor.com/docs/cli/installation.",318 "See https://cursor.com/docs/cli/installation.",
313].join(" ");319].join(" ");
314320
321describe("Cursor skills", () => {
322 it("discovers recursive project skills with project precedence", async () =>
323 await runNode(
324 Effect.gen(function* () {
325 const fileSystem = yield* FileSystem.FileSystem;
326 const path = yield* Path.Path;
327 const userHome = yield* fileSystem.makeTempDirectory({
328 directory: NodeOS.tmpdir(),
329 prefix: "cursor-skills-home-",
330 });
331 const workspace = yield* fileSystem.makeTempDirectory({
332 directory: NodeOS.tmpdir(),
333 prefix: "cursor-skills-workspace-",
334 });
335 const writeSkill = Effect.fn("writeCursorSkill")(function* (
336 root: string,
337 name: string,
338 contents: string,
339 ) {
340 const skillDirectory = path.join(root, name);
341 yield* fileSystem.makeDirectory(skillDirectory, { recursive: true });
342 yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents);
343 });
344
345 yield* writeSkill(
346 path.join(userHome, ".cursor", "skills"),
347 "review",
348 "---\ndescription: user review\n---\n",
349 );
350 yield* writeSkill(
351 path.join(workspace, ".agents", "skills", "nested"),
352 "review",
353 "---\nname: Review changes\ndescription: project review\n---\n",
354 );
355 yield* writeSkill(
356 path.join(workspace, ".cursor", "skills"),
357 "internal",
358 "---\nuser-invocable: false\n---\n",
359 );
360 yield* writeSkill(
361 path.join(workspace, ".cursor", "skills"),
362 "oversized",
363 "x".repeat(1_000_001),
364 );
365 yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true });
366 yield* fileSystem.writeFileString(
367 path.join(userHome, ".codex", "skills"),
368 "not a directory",
369 );
370
371 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
372 expect(skills).toEqual([
373 {
374 name: "internal",
375 path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"),
376 scope: "project",
377 enabled: true,
378 userInvocable: false,
379 },
380 {
381 name: "oversized",
382 path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"),
383 scope: "project",
384 enabled: true,
385 },
386 {
387 name: "review",
388 displayName: "Review changes",
389 description: "project review",
390 path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"),
391 scope: "project",
392 enabled: true,
393 },
394 ]);
395 expect(
396 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
397 ).toBe("Failure");
398 }),
399 ));
400
401 it("rewrites only discovered skill mentions into Cursor slash invocations", () => {
402 expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true);
403 expect(hasCursorSkillMention("please $review this")).toBe(true);
404 expect(
405 rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])),
406 ).toBe("use $review, keep $HOME and 5$review");
407 expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe(
408 "please /review this",
409 );
410 });
411});
412
315describe("getCursorFallbackModels", () => {413describe("getCursorFallbackModels", () => {
316 it("does not publish any built-in cursor models before ACP discovery", () => {414 it("does not publish any built-in cursor models before ACP discovery", () => {
317 expect(415 expect(
apps/server/src/provider/Layers/GrokProvider.ts+4-1
...@@ -453,7 +453,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -453,7 +453,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
453 ? { status: "unauthenticated" }453 ? { status: "unauthenticated" }
454 : { status: "unknown" };454 : { status: "unknown" };
455455
456 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd);456 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
457 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
458 Effect.orElseSucceed(() => []),
459 );
457460
458 const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe(461 const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe(
459 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),462 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
apps/web/src/components/chat/ChatComposer.tsx+26-16
...@@ -346,6 +346,8 @@ import { useAtomCommand } from "../../state/use-atom-command";...@@ -346,6 +346,8 @@ import { useAtomCommand } from "../../state/use-atom-command";
346import { serverEnvironment } from "../../state/server";346import { serverEnvironment } from "../../state/server";
347import type { ReviewCommentContext } from "../../reviewCommentContext";347import type { ReviewCommentContext } from "../../reviewCommentContext";
348348
349const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;
350
349const runtimeModeConfig: Record<351const runtimeModeConfig: Record<
350 RuntimeMode,352 RuntimeMode,
351 { label: string; description: string; icon: LucideIcon }353 { label: string; description: string; icon: LucideIcon }
...@@ -1130,6 +1132,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1130,6 +1132,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1130 reportFailure: false,1132 reportFailure: false,
1131 });1133 });
1132 const workspaceRefreshKeyRef = useRef<string | null>(null);1134 const workspaceRefreshKeyRef = useRef<string | null>(null);
1135 const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null);
1133 const hadWorkspaceSnapshotRef = useRef(false);1136 const hadWorkspaceSnapshotRef = useRef(false);
1134 useEffect(() => {1137 useEffect(() => {
1135 const hasWorkspaceSnapshot = Boolean(1138 const hasWorkspaceSnapshot = Boolean(
...@@ -1138,6 +1141,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1138,6 +1141,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1138 );1141 );
1139 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {1142 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
1140 workspaceRefreshKeyRef.current = null;1143 workspaceRefreshKeyRef.current = null;
1144 workspaceRefreshRetryRef.current = null;
1141 }1145 }
1142 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;1146 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
1143 }, [gitCwd, selectedProviderStatus]);1147 }, [gitCwd, selectedProviderStatus]);
...@@ -1150,28 +1154,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1150,28 +1154,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1150 if (workspaceRefreshKeyRef.current === key) return;1154 if (workspaceRefreshKeyRef.current === key) return;
1151 if (hasWorkspaceSnapshot) {1155 if (hasWorkspaceSnapshot) {
1152 workspaceRefreshKeyRef.current = key;1156 workspaceRefreshKeyRef.current = key;
1157 workspaceRefreshRetryRef.current = null;
1153 return;1158 return;
1154 }1159 }
1160 const retry = workspaceRefreshRetryRef.current;
1161 if (retry?.key === key && Date.now() < retry.notBefore) return;
1155 workspaceRefreshKeyRef.current = key;1162 workspaceRefreshKeyRef.current = key;
1163 const retryLater = () => {
1164 if (workspaceRefreshKeyRef.current !== key) return;
1165 workspaceRefreshKeyRef.current = null;
1166 workspaceRefreshRetryRef.current = {
1167 key,
1168 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
1169 };
1170 };
1156 void refreshProviders({1171 void refreshProviders({
1157 environmentId,1172 environmentId,
1158 input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd },1173 input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd },
1159 }).then(1174 }).then((result) => {
1160 (result) => {1175 const hasWorkspaceSnapshot =
1161 const hasWorkspaceSnapshot =1176 result._tag === "Success" &&
1162 result._tag === "Success" &&1177 result.value.providers
1163 result.value.providers1178 .find((provider) => provider.instanceId === selectedProviderEntry.instanceId)
1164 .find((provider) => provider.instanceId === selectedProviderEntry.instanceId)1179 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd);
1165 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd);1180 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {
1166 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {1181 retryLater();
1167 workspaceRefreshKeyRef.current = null;1182 }
1168 }1183 }, retryLater);
1169 },1184 }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]);
1170 () => {
1171 if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null;
1172 },
1173 );
1174 }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]);
1175 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(1185 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
1176 () => selectedProviderEntry?.models ?? [],1186 () => selectedProviderEntry?.models ?? [],
1177 [selectedProviderEntry],1187 [selectedProviderEntry],