From ec6ecd823f4da6996b37c41ef272fac71f858f8c Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Thu, 20 Aug 2026 21:33:14 +0200 Subject: [PATCH 1/3] fix(codex): show project-local skills in composer --- .../src/features/threads/ThreadComposer.tsx | 27 ++++++-- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.test.ts | 19 +++++- .../src/workspace/WorkspaceEntries.test.ts | 27 ++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 66 ++++++++++++++++++- apps/server/src/ws.ts | 4 ++ apps/web/src/components/chat/ChatComposer.tsx | 49 +++++++++----- .../client-runtime/src/providerSkills.test.ts | 38 +++++++++++ packages/client-runtime/src/providerSkills.ts | 11 ++++ .../src/state/projectCommands.ts | 6 ++ packages/contracts/src/rpc.ts | 9 +++ 11 files changed, 235 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 087a96ea424f..fbe7f4a98d72 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -65,7 +65,10 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; +import { mergeProviderSkills } from "@t3tools/client-runtime/providerSkills"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; import { type ExistingThreadSettingsRouteSession, @@ -354,6 +357,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + const projectSkillsQuery = useEnvironmentQuery( + selectedProviderStatus?.driver === "codex" && props.projectCwd + ? projectEnvironment.listSkills({ + environmentId: props.environmentId, + input: { cwd: props.projectCwd }, + }) + : null, + ); + const selectedProviderSkills = useMemo( + () => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []), + [projectSkillsQuery.data, selectedProviderStatus?.skills], + ); // ── Trigger detection ──────────────────────────────────── const [composerSelection, setComposerSelection] = useState(() => ({ @@ -434,7 +449,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const enabledSkills = selectedProviderSkills.filter((s) => s.enabled); const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { trimLeadingPattern: /^\$+/, }); @@ -531,7 +546,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, selectedProviderSkills, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -727,7 +742,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer @@ -783,7 +802,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ref={inputRef} multiline value={props.draftMessage} - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} selection={composerSelection} onChangeText={props.onChangeDraftMessage} onSelectionChange={handleSelectionChange} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..d609309f5649 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -77,6 +77,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, + [WS_METHODS.projectsListSkills]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7cda53f25326..9b7e77bffdcb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4739,7 +4739,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => + it.effect("routes websocket rpc project file and skill reads", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -4749,6 +4749,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { path.join(workspaceDir, "src", "index.ts"), "export const answer = 42;\n", ); + yield* fs.makeDirectory(path.join(workspaceDir, ".agents", "skills", "unslop"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workspaceDir, ".agents", "skills", "unslop", "SKILL.md"), + "---\nname: unslop\ndescription: Remove AI writing patterns.\n---\n", + ); yield* buildAppUnderTest(); @@ -4757,6 +4764,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(wsUrl, (client) => Effect.all({ listing: client[WS_METHODS.projectsListEntries]({ cwd: workspaceDir }), + skills: client[WS_METHODS.projectsListSkills]({ cwd: workspaceDir }), file: client[WS_METHODS.projectsReadFile]({ cwd: workspaceDir, relativePath: "src/index.ts", @@ -4766,6 +4774,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.isTrue(response.listing.entries.some((entry) => entry.path === "src/index.ts")); + assert.deepEqual(response.skills, [ + { + name: "unslop", + description: "Remove AI writing patterns.", + path: path.join(workspaceDir, ".agents", "skills", "unslop", "SKILL.md"), + enabled: true, + scope: "project", + }, + ]); assert.deepEqual(response.file, { relativePath: "src/index.ts", contents: "export const answer = 42;\n", diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d47aaaec8264..945ca73cf84c 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -123,6 +123,33 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); + describe("listSkills", () => { + it.effect("discovers project skills under .agents/skills", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-agent-skills-" }); + yield* writeTextFile( + cwd, + ".agents/skills/unslop/SKILL.md", + "---\nname: unslop\ndescription: Remove AI writing patterns.\n---\n", + ); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const skills = yield* workspaceEntries.listSkills({ cwd }); + + expect(skills).toEqual([ + { + name: "unslop", + description: "Remove AI writing patterns.", + path: path.join(cwd, ".agents", "skills", "unslop", "SKILL.md"), + enabled: true, + scope: "project", + }, + ]); + }), + ); + }); + describe("search", () => { it.effect("returns files and directories relative to cwd", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 28a30481b1b6..a8b1a4ad27a8 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -18,10 +18,12 @@ import type { ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ServerProviderSkill, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import { parse as parseYamlDocument } from "yaml"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -84,6 +86,32 @@ export const WorkspaceEntriesError = Schema.Union([ ]); export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; +const SKILL_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +function parseSkillFrontmatter(contents: string): { + readonly name?: string; + readonly description?: string; +} | null { + const match = SKILL_FRONTMATTER_PATTERN.exec(contents); + if (!match) return {}; + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + export class WorkspaceEntries extends Context.Service< WorkspaceEntries, { @@ -93,6 +121,9 @@ export class WorkspaceEntries extends Context.Service< readonly list: ( input: ProjectListEntriesInput, ) => Effect.Effect; + readonly listSkills: (input: { + readonly cwd: string; + }) => Effect.Effect>; readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; @@ -288,7 +319,40 @@ export const make = Effect.gen(function* () { }, ); - return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); + const listSkills: WorkspaceEntries["Service"]["listSkills"] = Effect.fn( + "WorkspaceEntries.listSkills", + )(function* ({ cwd }) { + const skillsRoot = path.join(cwd, ".agents", "skills"); + const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(skillsRoot)).pipe( + Effect.orElseSucceed((): string[] => []), + ); + const skills: ServerProviderSkill[] = []; + + for (const entry of entries.toSorted()) { + const skillPath = path.join(skillsRoot, entry, "SKILL.md"); + const contents = yield* Effect.tryPromise(() => NodeFSP.readFile(skillPath, "utf8")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (contents === undefined) continue; + + const frontmatter = parseSkillFrontmatter(contents); + if (frontmatter === null) continue; + const name = frontmatter.name ?? entry.trim(); + if (!name) continue; + + skills.push({ + name, + path: skillPath, + enabled: true, + scope: "project", + ...(frontmatter.description ? { description: frontmatter.description } : {}), + }); + } + + return skills; + }); + + return WorkspaceEntries.of({ browse, list, listSkills, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c5b7e50a8704..3727b13fcd31 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1824,6 +1824,10 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListSkills]: (input) => + observeRpcEffect(WS_METHODS.projectsListSkills, workspaceEntries.listSkills(input), { + "rpc.aggregate": "workspace", + }), [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect( WS_METHODS.projectsReadFile, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f808f4ffe0d7..c2787df975a7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -251,10 +251,15 @@ import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { + formatProviderSkillDisplayName, + mergeProviderSkills, +} from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; const runtimeModeConfig: Record< RuntimeMode, @@ -885,6 +890,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const projectSkillsQuery = useEnvironmentQuery( + selectedProvider === "codex" && gitCwd + ? projectEnvironment.listSkills({ + environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const selectedProviderSkills = useMemo( + () => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []), + [projectSkillsQuery.data, selectedProviderStatus?.skills], + ); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -1124,25 +1141,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; }, [ composerTrigger, planModeUiEnabled, selectedProvider, + selectedProviderSkills, selectedProviderStatus, workspaceEntries.entries, ]); @@ -1209,7 +1225,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && projectSkillsQuery.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -3203,7 +3220,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index fa6b460b990c..9fff293eaa38 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { formatProviderSkillDisplayName, + mergeProviderSkills, resolveProviderSkillSourceKind, } from "./providerSkills.ts"; @@ -75,3 +76,40 @@ describe("resolveProviderSkillSourceKind", () => { ).toBe("other"); }); }); + +describe("mergeProviderSkills", () => { + it("adds repository skills installed under .agents/skills", () => { + expect( + mergeProviderSkills( + [], + [ + { + name: "unslop", + path: ".agents/skills/unslop/SKILL.md", + enabled: true, + scope: "project", + }, + { + name: "poteto-mode", + path: ".agents/skills/poteto-mode/SKILL.md", + enabled: true, + scope: "project", + }, + ], + ), + ).toEqual([ + { + name: "poteto-mode", + path: ".agents/skills/poteto-mode/SKILL.md", + enabled: true, + scope: "project", + }, + { + name: "unslop", + path: ".agents/skills/unslop/SKILL.md", + enabled: true, + scope: "project", + }, + ]); + }); +}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index d24776d8525d..c42fd170ee6c 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -54,3 +54,14 @@ export function resolveProviderSkillSourceKind( return "other"; } } + +export function mergeProviderSkills( + providerSkills: ReadonlyArray, + projectSkills: ReadonlyArray, +): ServerProviderSkill[] { + const skillsByName = new Map(providerSkills.map((skill) => [skill.name, skill])); + for (const skill of projectSkills) { + skillsByName.set(skill.name, skill); + } + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..4b4654b48df8 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -66,6 +66,12 @@ export function createProjectEnvironmentAtoms( staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, }), + listSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:list-skills", + tag: WS_METHODS.projectsListSkills, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", tag: WS_METHODS.projectsReadFile, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..db0741ae9b7e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -154,6 +154,7 @@ import { import { ServerConfigStreamEvent, ServerConfig, + ServerProviderSkill, ServerProviderUpdateError, ServerProviderUpdateInput, ServerLifecycleStreamEvent, @@ -199,6 +200,7 @@ export const WS_METHODS = { projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", + projectsListSkills: "projects.listSkills", projectsReadFile: "projects.readFile", projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", @@ -636,6 +638,12 @@ export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); +export const WsProjectsListSkillsRpc = Rpc.make(WS_METHODS.projectsListSkills, { + payload: ProjectListEntriesInput, + success: Schema.Array(ServerProviderSkill), + error: EnvironmentAuthorizationError, +}); + export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, @@ -1027,6 +1035,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, + WsProjectsListSkillsRpc, WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, From 070c40cc3fa04702d47a8a65412b27559b609814 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Thu, 20 Aug 2026 21:41:51 +0200 Subject: [PATCH 2/3] fix(mobile): show skill picker while loading --- apps/mobile/src/features/threads/ThreadComposer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fbe7f4a98d72..335cecbea217 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -737,7 +737,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer layout={COMPOSER_LAYOUT_TRANSITION} style={{ maxWidth: props.contentMaxWidth }} > - {composerTrigger && composerMenuItems.length > 0 ? ( + {composerTrigger && + (composerMenuItems.length > 0 || + (composerTrigger.kind === "skill" && projectSkillsQuery.isPending)) ? ( Date: Thu, 20 Aug 2026 21:51:31 +0200 Subject: [PATCH 3/3] fix(mobile): scan skills from active worktree --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e234838394ba..4c8413f36052 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -728,7 +728,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} environmentId={props.environmentId} - projectCwd={props.projectWorkspaceRoot} + projectCwd={props.threadCwd} bottomInset={composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} onPickDraftImages={props.onPickDraftImages}