Skip to content

Commit b9d577e

Browse files
Ridoclaude
andcommitted
fix: discover project-level Claude skills for the picker in the desktop app
Provider snapshot skill discovery runs against the server process cwd, which only matches the user's repository in the npx-t3-inside-a-repo flow. In the desktop app that cwd is unrelated to the project open in the sidebar, so project-level skills (<project>/.claude/skills) never appeared in the $ picker even though they executed fine in threads. Add a server.discoverProviderSkills RPC that resolves skill discovery against the active project's own path (or worktree), exposed as an optional per-instance discoverSkills capability implemented by the Claude driver via the existing discoverClaudeSkills scan. The web app queries it per workspace and falls back to the snapshot skills while loading, on older servers, or for drivers without workspace discovery, so the npx flow and other providers are unchanged. Fixes #2048, fixes #2416. The Codex driver has the same server-cwd assumption (#3040) via its skills/list probe; the new per-instance capability is the seam to fix that separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V85GoTqK9nxfdvWvRo618z
1 parent 5719e8a commit b9d577e

11 files changed

Lines changed: 280 additions & 28 deletions

File tree

apps/server/src/provider/Drivers/ClaudeDriver.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
type ProviderSnapshotSettings,
5555
} from "../providerUpdateSettings.ts";
5656
import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
57+
import { discoverClaudeSkills } from "./ClaudeSkills.ts";
5758
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
5859

5960
const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
@@ -216,6 +217,15 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
216217
snapshot,
217218
adapter,
218219
textGeneration,
220+
// Workspace-scoped discovery for the `$` picker: the snapshot scans
221+
// against the server process cwd, which is unrelated to the active
222+
// project in the desktop app. Transports call this with the
223+
// project's own path instead.
224+
discoverSkills: (projectCwd: string) =>
225+
discoverClaudeSkills(effectiveConfig, projectCwd, processEnv).pipe(
226+
Effect.provideService(FileSystem.FileSystem, fileSystem),
227+
Effect.provideService(Path.Path, path),
228+
),
219229
} satisfies ProviderInstance;
220230
}),
221231
};

apps/server/src/provider/Drivers/ClaudeSkills.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,54 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
188188
}),
189189
);
190190

191+
it.effect("scopes project skills to the workspace passed in, not the process cwd", () =>
192+
Effect.gen(function* () {
193+
const fs = yield* FileSystem.FileSystem;
194+
const path = yield* Path.Path;
195+
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
196+
const configDir = path.join(tempDir, "claude-home");
197+
const projectAlpha = path.join(tempDir, "project-alpha");
198+
const projectBeta = path.join(tempDir, "project-beta");
199+
200+
yield* writeSkill(
201+
path.join(configDir, "skills"),
202+
"shared",
203+
["---", "name: shared", "description: User-scoped.", "---"].join("\n"),
204+
);
205+
yield* writeSkill(
206+
path.join(projectAlpha, ".claude", "skills"),
207+
"alpha-deploy",
208+
["---", "name: alpha-deploy", "---"].join("\n"),
209+
);
210+
yield* writeSkill(
211+
path.join(projectBeta, ".claude", "skills"),
212+
"beta-release",
213+
["---", "name: beta-release", "---"].join("\n"),
214+
);
215+
216+
// The desktop app runs the server from a cwd unrelated to any project;
217+
// discovery keyed by each project's own path must return that
218+
// project's skills without leaking the other project's set.
219+
const alphaSkills = yield* discoverClaudeSkills({ homePath: configDir }, projectAlpha);
220+
const betaSkills = yield* discoverClaudeSkills({ homePath: configDir }, projectBeta);
221+
222+
assert.deepEqual(
223+
alphaSkills.map((skill) => [skill.name, skill.scope]),
224+
[
225+
["alpha-deploy", "project"],
226+
["shared", "user"],
227+
],
228+
);
229+
assert.deepEqual(
230+
betaSkills.map((skill) => [skill.name, skill.scope]),
231+
[
232+
["beta-release", "project"],
233+
["shared", "user"],
234+
],
235+
);
236+
}),
237+
);
238+
191239
it.effect("returns an empty list when no skill roots exist", () =>
192240
Effect.gen(function* () {
193241
const fs = yield* FileSystem.FileSystem;

apps/server/src/provider/ProviderDriver.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
ProviderDriverKind,
2626
ProviderInstanceEnvironment,
2727
ProviderInstanceId,
28+
ServerProviderSkill,
2829
} from "@t3tools/contracts";
2930
import type * as Effect from "effect/Effect";
3031
import type * as Schema from "effect/Schema";
@@ -71,6 +72,17 @@ export interface ProviderInstance {
7172
readonly snapshot: ServerProviderShape;
7273
readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
7374
readonly textGeneration: TextGeneration.TextGeneration["Service"];
75+
/**
76+
* Best-effort skill discovery resolved against a specific workspace root.
77+
* The snapshot's `skills` are scanned against the server process cwd,
78+
* which only matches the user's repository in the `npx t3`-inside-a-repo
79+
* flow; transports call this with the active project's path so the `$`
80+
* picker sees that project's skills. Absent when the driver has no
81+
* workspace-scoped skill discovery — callers then fall back to the
82+
* snapshot's `skills`. Must never fail; unreadable roots yield fewer
83+
* skills, not errors.
84+
*/
85+
readonly discoverSkills?: (cwd: string) => Effect.Effect<ReadonlyArray<ServerProviderSkill>>;
7486
}
7587

7688
export interface ProviderContinuationIdentity {

apps/server/src/server.test.ts

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts";
8282
import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
8383
import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
8484
import { PersistenceSqlError } from "./persistence/Errors.ts";
85+
import type { ProviderInstance } from "./provider/ProviderDriver.ts";
86+
import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";
8587
import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
8688
import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
8789
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
@@ -323,6 +325,9 @@ const buildAppUnderTest = (options?: {
323325
layers?: {
324326
keybindings?: Partial<Keybindings.Keybindings["Service"]>;
325327
providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
328+
providerInstanceRegistry?: Partial<
329+
ProviderInstanceRegistry.ProviderInstanceRegistry["Service"]
330+
>;
326331
serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
327332
externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;
328333
vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;
@@ -544,18 +549,27 @@ const buildAppUnderTest = (options?: {
544549
}),
545550
),
546551
Layer.provide(
547-
Layer.mock(ProviderRegistry.ProviderRegistry)({
548-
getProviders: Effect.succeed([]),
549-
refresh: () => Effect.succeed([]),
550-
refreshInstance: () => Effect.succeed([]),
551-
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
552-
Effect.succeed(
553-
makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
554-
),
555-
setProviderMaintenanceActionState: () => Effect.succeed([]),
556-
streamChanges: Stream.empty,
557-
...options?.layers?.providerRegistry,
558-
}),
552+
Layer.mergeAll(
553+
Layer.mock(ProviderRegistry.ProviderRegistry)({
554+
getProviders: Effect.succeed([]),
555+
refresh: () => Effect.succeed([]),
556+
refreshInstance: () => Effect.succeed([]),
557+
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
558+
Effect.succeed(
559+
makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
560+
),
561+
setProviderMaintenanceActionState: () => Effect.succeed([]),
562+
streamChanges: Stream.empty,
563+
...options?.layers?.providerRegistry,
564+
}),
565+
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
566+
getInstance: () => Effect.succeed(undefined),
567+
listInstances: Effect.succeed([]),
568+
listUnavailable: Effect.succeed([]),
569+
streamChanges: Stream.empty,
570+
...options?.layers?.providerInstanceRegistry,
571+
}),
572+
),
559573
),
560574
Layer.provide(
561575
Layer.mock(ServerSettings.ServerSettingsService)({
@@ -4456,6 +4470,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
44564470
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
44574471
);
44584472

4473+
it.effect(
4474+
"routes websocket rpc server.discoverProviderSkills against the requested workspace",
4475+
() =>
4476+
Effect.gen(function* () {
4477+
const claudeInstanceId = ProviderInstanceId.make("claudeAgent");
4478+
const requestedCwds: Array<string> = [];
4479+
// The handler only reaches for `discoverSkills`; the rest of the
4480+
// instance is irrelevant to this route.
4481+
const fakeInstance = {
4482+
discoverSkills: (cwd: string) => {
4483+
requestedCwds.push(cwd);
4484+
return Effect.succeed([
4485+
{
4486+
name: "deploy",
4487+
path: `${cwd}/.claude/skills/deploy/SKILL.md`,
4488+
enabled: true,
4489+
scope: "project",
4490+
},
4491+
]);
4492+
},
4493+
} as unknown as ProviderInstance;
4494+
4495+
yield* buildAppUnderTest({
4496+
layers: {
4497+
providerInstanceRegistry: {
4498+
getInstance: (instanceId) =>
4499+
Effect.succeed(instanceId === claudeInstanceId ? fakeInstance : undefined),
4500+
},
4501+
},
4502+
});
4503+
4504+
const wsUrl = yield* getWsServerUrl("/ws");
4505+
const response = yield* Effect.scoped(
4506+
withWsRpcClient(wsUrl, (client) =>
4507+
Effect.all({
4508+
discovered: client[WS_METHODS.serverDiscoverProviderSkills]({
4509+
instanceId: claudeInstanceId,
4510+
cwd: "/projects/alpha",
4511+
}),
4512+
// Instances without workspace discovery (or unknown ids) answer
4513+
// `skills: null` so clients keep the snapshot's skills.
4514+
unsupported: client[WS_METHODS.serverDiscoverProviderSkills]({
4515+
instanceId: ProviderInstanceId.make("codex"),
4516+
cwd: "/projects/alpha",
4517+
}),
4518+
}),
4519+
),
4520+
);
4521+
4522+
assert.deepEqual(requestedCwds, ["/projects/alpha"]);
4523+
assert.deepEqual(response.discovered.skills, [
4524+
{
4525+
name: "deploy",
4526+
path: "/projects/alpha/.claude/skills/deploy/SKILL.md",
4527+
enabled: true,
4528+
scope: "project",
4529+
},
4530+
]);
4531+
assert.isNull(response.unsupported.skills);
4532+
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
4533+
);
4534+
44594535
it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () =>
44604536
Effect.gen(function* () {
44614537
const fs = yield* FileSystem.FileSystem;

apps/server/src/ws.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
observeRpcStream as instrumentRpcStream,
7777
observeRpcStreamEffect as instrumentRpcStreamEffect,
7878
} from "./observability/RpcInstrumentation.ts";
79+
import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";
7980
import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
8081
import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
8182
import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
@@ -297,6 +298,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
297298
[WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
298299
[WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
299300
[WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
301+
[WS_METHODS.serverDiscoverProviderSkills, AuthOrchestrationReadScope],
300302
[WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope],
301303
[WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
302304
[WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope],
@@ -419,6 +421,7 @@ const makeWsRpcLayer = (
419421
const previewManager = yield* PreviewManager.PreviewManager;
420422
const portDiscovery = yield* PortScanner.PortDiscovery;
421423
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
424+
const providerInstanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
422425
const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
423426
const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
424427
const config = yield* ServerConfig.ServerConfig;
@@ -1479,6 +1482,21 @@ const makeWsRpcLayer = (
14791482
"rpc.aggregate": "server",
14801483
},
14811484
),
1485+
[WS_METHODS.serverDiscoverProviderSkills]: (input) =>
1486+
observeRpcEffect(
1487+
WS_METHODS.serverDiscoverProviderSkills,
1488+
Effect.gen(function* () {
1489+
const instance = yield* providerInstanceRegistry.getInstance(input.instanceId);
1490+
// `skills: null` tells the client this driver has no
1491+
// workspace-scoped discovery, so it keeps using the snapshot's
1492+
// skills instead of treating the result as "no skills here".
1493+
if (!instance?.discoverSkills) {
1494+
return { skills: null };
1495+
}
1496+
return { skills: yield* instance.discoverSkills(input.cwd) };
1497+
}),
1498+
{ "rpc.aggregate": "server" },
1499+
),
14821500
[WS_METHODS.serverUpdateServer]: (input) =>
14831501
observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {
14841502
"rpc.aggregate": "server",

apps/web/src/components/ChatView.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ import { cn, randomHex } from "~/lib/utils";
151151
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
152152
import { stackedThreadToast, toastManager } from "./ui/toast";
153153
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
154+
import { useWorkspaceProviderSkills } from "~/state/queries";
154155
import { type NewProjectScriptInput } from "./ProjectScriptsControl";
155156
import {
156157
buildProjectScript,
@@ -2354,6 +2355,14 @@ function ChatViewContent(props: ChatViewProps) {
23542355
const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
23552356
return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
23562357
}, [activeProviderInstanceId, providerStatuses, selectedProvider]);
2358+
// Skills resolved against the active workspace (project path / worktree)
2359+
// rather than the server process cwd baked into the snapshot.
2360+
const workspaceProviderSkills = useWorkspaceProviderSkills({
2361+
environmentId,
2362+
instanceId: activeProviderStatus?.instanceId ?? null,
2363+
cwd: gitCwd,
2364+
snapshotSkills: activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS,
2365+
});
23572366
const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
23582367
const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
23592368
string | null
@@ -5698,7 +5707,7 @@ function ChatViewContent(props: ChatViewProps) {
56985707
resolvedTheme={resolvedTheme}
56995708
timestampFormat={timestampFormat}
57005709
workspaceRoot={activeWorkspaceRoot}
5701-
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
5710+
skills={workspaceProviderSkills}
57025711
anchorMessageId={timelineAnchorMessageId}
57035712
onAnchorReady={onTimelineAnchorReady}
57045713
onAnchorSizeChanged={onTimelineAnchorSizeChanged}

0 commit comments

Comments
 (0)