| author | |
| committer | |
| log | 15fea6c5f40331c8325f33bd08b2dcbf924e935a |
| tree | abd1d8747dfcd2404ee16bc0c6ec580113b261b7 |
| parent | 443b4ebfe83fcfe64c34b09ecb5a5fffdebb85c7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1053 | multiline |
| 1054 | 1054 | scrollEnabled |
| 1055 | 1055 | value={flow.prompt} |
| 1056 | skills={flow.selectedProviderStatus?.skills ?? []} | |
| 1056 | skills={composerMenu.skills} | |
| 1057 | 1057 | selection={composerMenu.selection} |
| 1058 | 1058 | onChangeText={flow.setPrompt} |
| 1059 | 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 | 652 | multiline |
| 653 | 653 | value={props.draftMessage} |
| 654 | 654 | readOnly={voiceInput.freezesEditor} |
| 655 | skills={selectedProviderStatus?.skills ?? []} | |
| 655 | skills={composerMenu.skills} | |
| 656 | 656 | selection={composerMenu.selection} |
| 657 | 657 | onChangeText={props.onChangeDraftMessage} |
| 658 | 658 | onSelectionChange={composerMenu.onSelectionChange} |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+10-7| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 6 | 6 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; |
| 7 | 7 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 8 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | |
| 8 | 9 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 9 | 10 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 10 | 11 | import type { |
| ... | ... | @@ -510,12 +511,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 510 | 511 | const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; |
| 511 | 512 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; |
| 512 | 513 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 513 | const selectedProviderSkills = useMemo( | |
| 514 | () => | |
| 515 | props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) | |
| 516 | ?.skills ?? [], | |
| 517 | [props.serverConfig, selectedInstanceId], | |
| 518 | ); | |
| 514 | const selectedProviderSkills = useMemo(() => { | |
| 515 | const provider = props.serverConfig?.providers.find( | |
| 516 | (candidate) => candidate.instanceId === selectedInstanceId, | |
| 517 | ); | |
| 518 | return provider | |
| 519 | ? resolveProviderSkillsForCwd(provider, props.threadCwd ?? props.projectWorkspaceRoot) | |
| 520 | : []; | |
| 521 | }, [props.projectWorkspaceRoot, props.serverConfig, props.threadCwd, selectedInstanceId]); | |
| 519 | 522 | |
| 520 | 523 | useLayoutEffect(() => { |
| 521 | 524 | selectedThreadKeyRef.current = selectedThreadKey; |
| ... | ... | @@ -809,7 +812,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 809 | 812 | serverConfig={props.serverConfig} |
| 810 | 813 | queueCount={props.selectedThreadQueueCount} |
| 811 | 814 | environmentId={props.environmentId} |
| 812 | projectCwd={props.projectWorkspaceRoot} | |
| 815 | projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} | |
| 813 | 816 | bottomInset={composerBottomInset} |
| 814 | 817 | onChangeDraftMessage={props.onChangeDraftMessage} |
| 815 | 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 | 3 | vi.mock("../../state/queries", () => ({ |
| 4 | 4 | useComposerPathSearch: () => ({ entries: [], isPending: false }), |
| 5 | 5 | })); |
| 6 | vi.mock("../../state/server", () => ({ | |
| 7 | serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, | |
| 8 | })); | |
| 9 | vi.mock("../../state/use-atom-command", () => ({ | |
| 10 | useAtomCommand: () => vi.fn(), | |
| 11 | })); | |
| 6 | 12 | |
| 7 | 13 | import { composerSelectionAtEnd } from "./use-composer-command-menu"; |
| 8 | 14 |
apps/mobile/src/features/threads/use-composer-command-menu.ts+84-7| ... | ... | @@ -13,14 +13,19 @@ import { |
| 13 | 13 | dedupeProviderSkillsByName, |
| 14 | 14 | getProviderSkillsForSlashMenu, |
| 15 | 15 | isProviderSkillUserInvocable, |
| 16 | resolveProviderSkillsForCwd, | |
| 16 | 17 | } from "@t3tools/client-runtime/providerSkills"; |
| 17 | 18 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 18 | 19 | |
| 19 | 20 | import type { ComposerEditorSelection } from "../../components/ComposerEditor"; |
| 21 | import { serverEnvironment } from "../../state/server"; | |
| 22 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 20 | 23 | import { useComposerPathSearch } from "../../state/queries"; |
| 21 | 24 | import type { ComposerCommandItem } from "./ComposerCommandPopover"; |
| 22 | 25 | import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; |
| 23 | 26 | |
| 27 | const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; | |
| 28 | ||
| 24 | 29 | export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { |
| 25 | 30 | return { start: draftMessage.length, end: draftMessage.length }; |
| 26 | 31 | } |
| ... | ... | @@ -69,6 +74,72 @@ export function useComposerCommandMenu({ |
| 69 | 74 | setSelection(composerSelectionAtEnd(draftMessage)); |
| 70 | 75 | }, [draftMessage, ownerKey]); |
| 71 | 76 | |
| 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 | 143 | const trigger = useMemo(() => { |
| 73 | 144 | if (!enabled || selection.start !== selection.end) { |
| 74 | 145 | return null; |
| ... | ... | @@ -120,8 +191,7 @@ export function useComposerCommandMenu({ |
| 120 | 191 | // locally and skills insert a `$` mention the server dispatches from |
| 121 | 192 | // any position, so only provider commands are position-gated. |
| 122 | 193 | const providerCommands: ComposerCommandItem[] = []; |
| 123 | const expandableCommands = | |
| 124 | trigger.rangeStart === 0 ? (selectedProviderStatus?.slashCommands ?? []) : []; | |
| 194 | const expandableCommands = trigger.rangeStart === 0 ? slashCommands : []; | |
| 125 | 195 | for (const command of expandableCommands) { |
| 126 | 196 | if (!command.name.toLowerCase().includes(q)) continue; |
| 127 | 197 | // Codex feedback uploads an existing thread's session and logs. |
| ... | ... | @@ -141,7 +211,7 @@ export function useComposerCommandMenu({ |
| 141 | 211 | }); |
| 142 | 212 | } |
| 143 | 213 | |
| 144 | const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true) | |
| 214 | const skillItems = getProviderSkillsForSlashMenu(skills, true) | |
| 145 | 215 | .filter((skill) => matchesSlashSkillQuery(skill, q)) |
| 146 | 216 | .map((skill) => ({ |
| 147 | 217 | id: `skill:${skill.name}`, |
| ... | ... | @@ -155,9 +225,7 @@ export function useComposerCommandMenu({ |
| 155 | 225 | } |
| 156 | 226 | |
| 157 | 227 | if (trigger.kind === "skill") { |
| 158 | const enabledSkills = dedupeProviderSkillsByName( | |
| 159 | (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable), | |
| 160 | ); | |
| 228 | const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); | |
| 161 | 229 | const normalizedQuery = normalizeSearchQuery(trigger.query, { |
| 162 | 230 | trimLeadingPattern: /^\$+/, |
| 163 | 231 | }); |
| ... | ... | @@ -254,7 +322,15 @@ export function useComposerCommandMenu({ |
| 254 | 322 | } |
| 255 | 323 | |
| 256 | 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 | ]); | |
| 258 | 334 | |
| 259 | 335 | const onSelect = useCallback( |
| 260 | 336 | (item: ComposerCommandItem) => { |
| ... | ... | @@ -299,6 +375,7 @@ export function useComposerCommandMenu({ |
| 299 | 375 | onSelectionChange, |
| 300 | 376 | trigger, |
| 301 | 377 | items, |
| 378 | skills, | |
| 302 | 379 | isLoading: pathSearch.isPending, |
| 303 | 380 | onSelect, |
| 304 | 381 | }; |
apps/server/src/provider/Drivers/CursorDriver.ts+20| ... | ... | @@ -50,6 +50,7 @@ import { |
| 50 | 50 | makeProviderSnapshotSettingsSource, |
| 51 | 51 | type ProviderSnapshotSettings, |
| 52 | 52 | } from "../providerUpdateSettings.ts"; |
| 53 | import { probeCursorSkills } from "./CursorSkills.ts"; | |
| 53 | 54 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 54 | 55 | |
| 55 | 56 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| ... | ... | @@ -166,6 +167,25 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 166 | 167 | accentColor, |
| 167 | 168 | enabled, |
| 168 | 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 | 189 | adapter, |
| 170 | 190 | textGeneration, |
| 171 | 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 | */ | |
| 11 | import * as NodeOS from "node:os"; | |
| 12 | ||
| 13 | import type { ServerProviderSkill } from "@t3tools/contracts"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as FileSystem from "effect/FileSystem"; | |
| 16 | import * as Path from "effect/Path"; | |
| 17 | import * as PlatformError from "effect/PlatformError"; | |
| 18 | import * as Schema from "effect/Schema"; | |
| 19 | import { parse as parseYamlDocument } from "yaml"; | |
| 20 | ||
| 21 | const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; | |
| 22 | const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; | |
| 23 | const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/; | |
| 24 | const MAX_SKILL_DEPTH = 10; | |
| 25 | const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); | |
| 26 | const MAX_SKILL_SCAN_ENTRIES = 10_000; | |
| 27 | const MAX_SKILL_SCAN_BYTES = FileSystem.Size(8_000_000); | |
| 28 | ||
| 29 | interface CursorSkillFrontmatter { | |
| 30 | readonly description?: string; | |
| 31 | readonly displayName?: string; | |
| 32 | readonly userInvocationOnly?: boolean; | |
| 33 | readonly userInvocable?: boolean; | |
| 34 | readonly cliVisible: boolean; | |
| 35 | } | |
| 36 | ||
| 37 | interface CursorSkillScanBudget { | |
| 38 | remainingEntries: number; | |
| 39 | remainingBytes: bigint; | |
| 40 | exhausted: boolean; | |
| 41 | incomplete: boolean; | |
| 42 | } | |
| 43 | ||
| 44 | class 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 | ||
| 57 | const 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 | ||
| 71 | function 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 | ||
| 89 | function 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 | ||
| 128 | const 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 | ||
| 212 | const 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 | ||
| 250 | export 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 | ||
| 257 | export 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`. */ | |
| 272 | export function hasCursorSkillMention(prompt: string): boolean { | |
| 273 | return HAS_SKILL_MENTION_PATTERN.test(prompt); | |
| 274 | } | |
| 275 | ||
| 276 | export 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 | 27 | } from "../ProviderDriver.ts"; |
| 28 | 28 | import { withInstanceIdentity } from "./instanceIdentity.ts"; |
| 29 | 29 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 30 | import { discoverGrokSkills } from "./GrokSkills.ts"; | |
| 30 | 31 | import { |
| 31 | 32 | makeManualOnlyProviderMaintenanceCapabilities, |
| 32 | 33 | makeStaticProviderMaintenanceResolver, |
| ... | ... | @@ -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 }))); | |
| 136 | 155 | |
| 137 | 156 | return { |
| 138 | 157 | instanceId, |
| ... | ... | @@ -142,6 +161,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 142 | 161 | accentColor, |
| 143 | 162 | enabled, |
| 144 | 163 | snapshot, |
| 164 | snapshotForCwd, | |
| 145 | 165 | adapter, |
| 146 | 166 | textGeneration, |
| 147 | 167 | } satisfies ProviderInstance; |
apps/server/src/provider/Drivers/GrokSkills.test.ts+10-2| ... | ... | @@ -93,14 +93,15 @@ describe("parseGrokInspectSkills", () => { |
| 93 | 93 | }); |
| 94 | 94 | |
| 95 | 95 | describe("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 | 97 | const spawnCwds: Array<string | undefined> = []; |
| 98 | let exitCode = 0; | |
| 98 | 99 | const spawner = ChildProcessSpawner.make((command) => { |
| 99 | 100 | spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); |
| 100 | 101 | return Effect.succeed( |
| 101 | 102 | ChildProcessSpawner.makeHandle({ |
| 102 | 103 | pid: ChildProcessSpawner.ProcessId(1), |
| 103 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), | |
| 104 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | |
| 104 | 105 | isRunning: Effect.succeed(false), |
| 105 | 106 | kill: () => Effect.void, |
| 106 | 107 | unref: Effect.succeed(Effect.void), |
| ... | ... | @@ -130,6 +131,13 @@ describe("discoverGrokSkills", () => { |
| 130 | 131 | |
| 131 | 132 | expect(spawnCwds).toEqual(["/workspaces/demo"]); |
| 132 | 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 | 9 | * (ignore lists, disabled skills) and includes plugin skills, which live |
| 10 | 10 | * three levels deep under `~/.grok/installed-plugins/` where a flat scan |
| 11 | 11 | * 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`, | |
| 13 | * a timeout, or malformed output yields an empty list, never a degraded | |
| 14 | * provider snapshot. | |
| 12 | * `skills/list`. Probe failures stay typed so workspace snapshots do not | |
| 13 | * cache an empty catalog; machine-level discovery recovers them to an empty | |
| 14 | * list without degrading the provider. | |
| 15 | 15 | * |
| 16 | 16 | * @module provider/Drivers/GrokSkills |
| 17 | 17 | */ |
| 18 | 18 | import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; |
| 19 | 19 | import * as Effect from "effect/Effect"; |
| 20 | 20 | import * as Option from "effect/Option"; |
| 21 | import * as Result from "effect/Result"; | |
| 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 21 | import * as Schema from "effect/Schema"; | |
| 22 | import { ChildProcess } from "effect/unstable/process"; | |
| 23 | 23 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 24 | 24 | |
| 25 | 25 | import { spawnAndCollect } from "../providerSnapshot.ts"; |
| 26 | 26 | |
| 27 | 27 | const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; |
| 28 | 28 | |
| 29 | class 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 | 46 | * Map `grok inspect --json` output onto provider skills. Entries without a |
| 31 | 47 | * name or a filesystem path are skipped; `userInvocable: false` skills are |
| 32 | 48 | * kept but disabled so pickers that filter on `enabled` hide them. |
| 33 | 49 | */ |
| 34 | export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> { | |
| 50 | function decodeGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> | undefined { | |
| 35 | 51 | let parsed: unknown; |
| 36 | 52 | try { |
| 37 | 53 | parsed = JSON.parse(stdout); |
| 38 | 54 | } catch { |
| 39 | return []; | |
| 55 | return undefined; | |
| 40 | 56 | } |
| 41 | 57 | if (typeof parsed !== "object" || parsed === null) { |
| 42 | return []; | |
| 58 | return undefined; | |
| 43 | 59 | } |
| 44 | 60 | const entries = (parsed as Record<string, unknown>).skills; |
| 45 | 61 | if (!Array.isArray(entries)) { |
| 46 | return []; | |
| 62 | return undefined; | |
| 47 | 63 | } |
| 48 | 64 | |
| 49 | 65 | const skillsByName = new Map<string, ServerProviderSkill>(); |
| ... | ... | @@ -75,20 +91,20 @@ export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProv |
| 75 | 91 | return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); |
| 76 | 92 | } |
| 77 | 93 | |
| 94 | export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> { | |
| 95 | return decodeGrokInspectSkills(stdout) ?? []; | |
| 96 | } | |
| 97 | ||
| 78 | 98 | /** |
| 79 | 99 | * Run `grok inspect --json` and map the reported catalog onto provider |
| 80 | * skills. Never fails: any spawn error, non-zero exit, or timeout resolves | |
| 81 | * to an empty list. | |
| 100 | * skills. Callers that need best-effort discovery can recover this effect to | |
| 101 | * an empty list; workspace callers leave failures typed so they are not cached. | |
| 82 | 102 | */ |
| 83 | 103 | export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( |
| 84 | 104 | grokSettings: Pick<GrokSettings, "binaryPath">, |
| 85 | 105 | environment: NodeJS.ProcessEnv = process.env, |
| 86 | 106 | cwd?: string, |
| 87 | ): Effect.fn.Return< | |
| 88 | ReadonlyArray<ServerProviderSkill>, | |
| 89 | never, | |
| 90 | ChildProcessSpawner.ChildProcessSpawner | |
| 91 | > { | |
| 107 | ) { | |
| 92 | 108 | const command = grokSettings.binaryPath || "grok"; |
| 93 | 109 | const inspectResult = yield* Effect.gen(function* () { |
| 94 | 110 | const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], { |
| ... | ... | @@ -102,18 +118,38 @@ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( |
| 102 | 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 | ); | |
| 106 | 132 | |
| 107 | if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) { | |
| 108 | yield* Effect.logDebug("Grok skill discovery failed; continuing without skills."); | |
| 109 | return []; | |
| 133 | if (Option.isNone(inspectResult)) { | |
| 134 | return yield* new GrokSkillsProbeError({ | |
| 135 | stage: "timeout", | |
| 136 | ...(cwd ? { cwd } : {}), | |
| 137 | }); | |
| 110 | 138 | } |
| 111 | const output = inspectResult.success.value; | |
| 139 | const output = inspectResult.value; | |
| 112 | 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 | 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 | 250 | }), |
| 251 | 251 | ); |
| 252 | 252 | |
| 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 | 299 | it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => |
| 254 | 300 | Effect.gen(function* () { |
| 255 | 301 | const adapter = yield* CursorAdapter; |
apps/server/src/provider/Layers/CursorAdapter.ts+29-2| ... | ... | @@ -77,6 +77,11 @@ import { |
| 77 | 77 | import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; |
| 78 | 78 | import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; |
| 79 | 79 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 80 | import { | |
| 81 | discoverCursorSkills, | |
| 82 | hasCursorSkillMention, | |
| 83 | rewriteCursorSkillMentions, | |
| 84 | } from "../Drivers/CursorSkills.ts"; | |
| 80 | 85 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); |
| 81 | 86 | |
| 82 | 87 | const PROVIDER = ProviderDriverKind.make("cursor"); |
| ... | ... | @@ -133,6 +138,7 @@ interface CursorSessionContext { |
| 133 | 138 | readonly turns: Array<{ id: TurnId; items: Array<unknown> }>; |
| 134 | 139 | lastPlanFingerprint: string | undefined; |
| 135 | 140 | activeTurnId: TurnId | undefined; |
| 141 | cursorSkillNames: ReadonlySet<string> | undefined; | |
| 136 | 142 | /** Number of sendTurn prompts currently in flight or being prepared. |
| 137 | 143 | * >0 means a turn is actively running, so a new sendTurn is a steer that |
| 138 | 144 | * continues it, and only the last remaining prompt settles the turn. */ |
| ... | ... | @@ -779,6 +785,7 @@ export function makeCursorAdapter( |
| 779 | 785 | turns: [], |
| 780 | 786 | lastPlanFingerprint: undefined, |
| 781 | 787 | activeTurnId: undefined, |
| 788 | cursorSkillNames: undefined, | |
| 782 | 789 | promptsInFlight: 0, |
| 783 | 790 | stopped: false, |
| 784 | 791 | }; |
| ... | ... | @@ -968,8 +975,28 @@ export function makeCursorAdapter( |
| 968 | 975 | } |
| 969 | 976 | |
| 970 | 977 | const promptParts: Array<EffectAcpSchema.ContentBlock> = []; |
| 971 | if (input.input?.trim()) { | |
| 972 | promptParts.push({ type: "text", text: input.input.trim() }); | |
| 978 | const rawPrompt = 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 | 1001 | if (input.attachments && input.attachments.length > 0) { |
| 975 | 1002 | for (const attachment of input.attachments) { |
apps/server/src/provider/Layers/CursorProvider.test.ts+98| ... | ... | @@ -24,6 +24,12 @@ import { |
| 24 | 24 | resolveCursorAcpBaseModelId, |
| 25 | 25 | resolveCursorAcpConfigUpdates, |
| 26 | 26 | } from "./CursorProvider.ts"; |
| 27 | import { | |
| 28 | discoverCursorSkills, | |
| 29 | hasCursorSkillMention, | |
| 30 | probeCursorSkills, | |
| 31 | rewriteCursorSkillMentions, | |
| 32 | } from "../Drivers/CursorSkills.ts"; | |
| 27 | 33 | |
| 28 | 34 | const runNode = <A, E>( |
| 29 | 35 | effect: Effect.Effect< |
| ... | ... | @@ -312,6 +318,98 @@ const cursorCliCommandMissingMessage = [ |
| 312 | 318 | "See https://cursor.com/docs/cli/installation.", |
| 313 | 319 | ].join(" "); |
| 314 | 320 | |
| 321 | describe("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 | ||
| 315 | 413 | describe("getCursorFallbackModels", () => { |
| 316 | 414 | it("does not publish any built-in cursor models before ACP discovery", () => { |
| 317 | 415 | expect( |
apps/server/src/provider/Layers/GrokProvider.ts+4-1| ... | ... | @@ -453,7 +453,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 453 | 453 | ? { status: "unauthenticated" } |
| 454 | 454 | : { status: "unknown" }; |
| 455 | 455 | |
| 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 | ); | |
| 457 | 460 | |
| 458 | 461 | const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe( |
| 459 | 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 | 346 | import { serverEnvironment } from "../../state/server"; |
| 347 | 347 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 348 | 348 | |
| 349 | const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; | |
| 350 | ||
| 349 | 351 | const runtimeModeConfig: Record< |
| 350 | 352 | RuntimeMode, |
| 351 | 353 | { label: string; description: string; icon: LucideIcon } |
| ... | ... | @@ -1130,6 +1132,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1130 | 1132 | reportFailure: false, |
| 1131 | 1133 | }); |
| 1132 | 1134 | const workspaceRefreshKeyRef = useRef<string | null>(null); |
| 1135 | const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); | |
| 1133 | 1136 | const hadWorkspaceSnapshotRef = useRef(false); |
| 1134 | 1137 | useEffect(() => { |
| 1135 | 1138 | const hasWorkspaceSnapshot = Boolean( |
| ... | ... | @@ -1138,6 +1141,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1138 | 1141 | ); |
| 1139 | 1142 | if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { |
| 1140 | 1143 | workspaceRefreshKeyRef.current = null; |
| 1144 | workspaceRefreshRetryRef.current = null; | |
| 1141 | 1145 | } |
| 1142 | 1146 | hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; |
| 1143 | 1147 | }, [gitCwd, selectedProviderStatus]); |
| ... | ... | @@ -1150,28 +1154,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1150 | 1154 | if (workspaceRefreshKeyRef.current === key) return; |
| 1151 | 1155 | if (hasWorkspaceSnapshot) { |
| 1152 | 1156 | workspaceRefreshKeyRef.current = key; |
| 1157 | workspaceRefreshRetryRef.current = null; | |
| 1153 | 1158 | return; |
| 1154 | 1159 | } |
| 1160 | const retry = workspaceRefreshRetryRef.current; | |
| 1161 | if (retry?.key === key && Date.now() < retry.notBefore) return; | |
| 1155 | 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 | 1171 | void refreshProviders({ |
| 1157 | 1172 | environmentId, |
| 1158 | 1173 | input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, |
| 1159 | }).then( | |
| 1160 | (result) => { | |
| 1161 | const hasWorkspaceSnapshot = | |
| 1162 | result._tag === "Success" && | |
| 1163 | result.value.providers | |
| 1164 | .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) | |
| 1165 | ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); | |
| 1166 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { | |
| 1167 | workspaceRefreshKeyRef.current = null; | |
| 1168 | } | |
| 1169 | }, | |
| 1170 | () => { | |
| 1171 | if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null; | |
| 1172 | }, | |
| 1173 | ); | |
| 1174 | }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]); | |
| 1174 | }).then((result) => { | |
| 1175 | const hasWorkspaceSnapshot = | |
| 1176 | result._tag === "Success" && | |
| 1177 | result.value.providers | |
| 1178 | .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) | |
| 1179 | ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); | |
| 1180 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { | |
| 1181 | retryLater(); | |
| 1182 | } | |
| 1183 | }, retryLater); | |
| 1184 | }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]); | |
| 1175 | 1185 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| 1176 | 1186 | () => selectedProviderEntry?.models ?? [], |
| 1177 | 1187 | [selectedProviderEntry], |