Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hasProviderWorkspaceSkills } from "@t3tools/contracts";
import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts";
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
import {
Expand Down Expand Up @@ -203,10 +204,7 @@ export function useComposerCommandMenu({
reportFailure: false,
});
const selectedProviderInstanceId = selectedProviderStatus?.instanceId;
const hasWorkspaceSnapshot = Boolean(
projectCwd &&
selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd),
);
const hasWorkspaceSnapshot = hasProviderWorkspaceSkills(selectedProviderStatus, projectCwd);
const workspaceRefreshKeyRef = useRef<string | null>(null);
const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null);
const hadWorkspaceSnapshotRef = useRef(false);
Expand Down Expand Up @@ -243,9 +241,12 @@ export function useComposerCommandMenu({
}).then((result) => {
const refreshed =
result._tag === "Success" &&
result.value.providers
.find((provider) => provider.instanceId === selectedProviderInstanceId)
?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd);
hasProviderWorkspaceSkills(
result.value.providers.find(
(provider) => provider.instanceId === selectedProviderInstanceId,
),
projectCwd,
);
if (!refreshed && workspaceRefreshKeyRef.current === key) {
retryLater();
}
Expand Down
31 changes: 18 additions & 13 deletions apps/server/src/provider/Drivers/AntigravityDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,20 +379,25 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
snapshotForCwd: (cwd) =>
!enabled
? provider.snapshot.getSnapshot
: discoverAntigravitySkills({ cwd, userHome }).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)),
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER,
instanceId,
detail: "Could not read Antigravity workspace skills.",
cause,
}),
: provider
.snapshotForCwd(
cwd,
discoverAntigravitySkills({ cwd, userHome }).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
)
.pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER,
instanceId,
detail: "Could not read Antigravity workspace skills.",
cause,
}),
),
),
),
adapter,
textGeneration,
auth: authFlow.controller,
Expand Down
22 changes: 20 additions & 2 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts";
import * as ModelManifest from "../ModelManifest.ts";
import {
defaultProviderContinuationIdentity,
Expand Down Expand Up @@ -236,9 +237,26 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
? snapshot.getSnapshot
: Effect.all([
snapshot.getSnapshot,
discoverClaudeSkills(effectiveConfig, cwd, processEnv),
discoverClaudeSkills(effectiveConfig, cwd, processEnv).pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Failed to discover Claude skills for '${cwd}'`,
cause,
}),
),
),
]).pipe(
Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
Effect.map(([machineSnapshot, skills]) => ({
...machineSnapshot,
skills,
inventory: {
...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY),
skills: "authoritative" as const,
},
})),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
);
Expand Down
30 changes: 29 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";

import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts";

Expand All @@ -20,6 +21,33 @@ const writeSkill = Effect.fn(function* (
});

it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
it.effect("does not treat unreadable skill files or roots as an empty inventory", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-claude-skill-read-failure-",
});
yield* writeSkill(path.join(configDir, "skills"), "review", "# Review changes");

for (const method of ["readDirectory", "readFileString"] as const) {
const failure = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method,
});
const error = yield* discoverClaudeSkills({ homePath: configDir }).pipe(
Effect.provideService(FileSystem.FileSystem, {
...fs,
[method]: () => Effect.fail(failure),
}),
Effect.flip,
);
assert.strictEqual(error, failure);
}
}),
);

it.effect("discovers user and project skills with frontmatter metadata", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -331,7 +359,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
}),
);

it.effect("ignores unreadable settings when resolving skillOverrides", () =>
it.effect("ignores malformed settings when resolving skillOverrides", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down
50 changes: 32 additions & 18 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,19 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import type * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { parse as parseYamlDocument } from "yaml";

import { expandHomePath } from "../../pathExpansion.ts";

const readIfPresent = <A, R>(effect: Effect.Effect<A, PlatformError.PlatformError, R>) =>
effect.pipe(
Effect.catch((error) => (error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error))),
);

type ClaudeSkillScope = "user" | "project";

const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
Expand Down Expand Up @@ -161,14 +167,16 @@ export function skillOverrideSettingsPaths(
*/
const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* (
cwd: string,
): Effect.fn.Return<string | undefined, never, FileSystem.FileSystem | Path.Path> {
): Effect.fn.Return<
string | undefined,
PlatformError.PlatformError,
FileSystem.FileSystem | Path.Path
> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
let current = path.resolve(cwd);
while (true) {
const isRoot = yield* fileSystem
.exists(path.join(current, ".git"))
.pipe(Effect.orElseSucceed(() => false));
const isRoot = yield* fileSystem.exists(path.join(current, ".git"));
if (isRoot) {
return current;
}
Expand Down Expand Up @@ -223,7 +231,11 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
configDirPath: string,
cwd: string | undefined,
environment: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyMap<string, SkillOverride>, never, FileSystem.FileSystem | Path.Path> {
): Effect.fn.Return<
ReadonlyMap<string, SkillOverride>,
PlatformError.PlatformError,
FileSystem.FileSystem | Path.Path
> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
Expand All @@ -238,9 +250,7 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
environment,
repositoryRoot,
)) {
const contents = yield* fileSystem
.readFileString(settingsPath)
.pipe(Effect.orElseSucceed(() => undefined));
const contents = yield* readIfPresent(fileSystem.readFileString(settingsPath));
if (contents === undefined) {
continue;
}
Expand Down Expand Up @@ -297,9 +307,9 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct

/**
* Enumerate Claude Code skills from the user config dir and the workspace
* `.claude/skills`. Discovery is best-effort: unreadable roots and malformed
* skill entries are skipped so a broken skill never degrades the provider
* snapshot. Roots are listed highest precedence first and the first hit for a
* `.claude/skills`. Missing roots and malformed skill entries are skipped.
* Read failures stay failures so a refresh cannot cache an incomplete list.
* Roots are listed highest precedence first and the first hit for a
* name wins, matching Claude Code: verified against the CLI with the same
* skill name in both scopes, the user copy is the one that runs. Reporting the
* project copy instead would attach its invocation metadata to a command
Expand All @@ -309,7 +319,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
config: Pick<ClaudeSettings, "homePath">,
cwd?: string,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
): Effect.fn.Return<
ReadonlyArray<ServerProviderSkill>,
PlatformError.PlatformError,
FileSystem.FileSystem | Path.Path
> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
Expand All @@ -322,15 +336,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*

const skillsByName = new Map<string, ServerProviderSkill>();
for (const root of roots) {
const entries = yield* fileSystem
.readDirectory(root.directory)
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
const entries = (yield* readIfPresent(fileSystem.readDirectory(root.directory))) ?? [];

for (const entry of [...entries].sort()) {
const directory = yield* readIfPresent(fileSystem.stat(path.join(root.directory, entry)));
if (directory?.type !== "Directory") continue;
const skillPath = path.join(root.directory, entry, "SKILL.md");
const contents = yield* fileSystem
.readFileString(skillPath)
.pipe(Effect.orElseSucceed(() => undefined));
const file = yield* readIfPresent(fileSystem.stat(skillPath));
if (file?.type !== "File") continue;
const contents = yield* readIfPresent(fileSystem.readFileString(skillPath));
if (contents === undefined) {
continue;
}
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts";
import * as ModelManifest from "../ModelManifest.ts";
import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
import { withInstanceIdentity } from "./instanceIdentity.ts";
Expand Down Expand Up @@ -259,7 +260,14 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
]).pipe(
Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
Effect.map(([machineSnapshot, skills]) => ({
...machineSnapshot,
skills,
inventory: {
...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY),
skills: "authoritative" as const,
},
})),
Effect.mapError(
(cause) =>
new ProviderDriverError({
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "../Layers/CursorProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
Expand Down Expand Up @@ -214,7 +215,16 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
}),
),
),
]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))),
]).pipe(
Effect.map(([machineSnapshot, skills]) => ({
...machineSnapshot,
skills,
inventory: {
...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY),
skills: "authoritative" as const,
},
})),
),
adapter,
textGeneration,
} satisfies ProviderInstance;
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/provider/Drivers/GrokDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "../Layers/GrokProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
Expand Down Expand Up @@ -140,7 +141,16 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
}),
),
),
]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })));
]).pipe(
Effect.map(([machineSnapshot, skills]) => ({
...machineSnapshot,
skills,
inventory: {
...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY),
skills: "authoritative" as const,
},
})),
);

return {
instanceId,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/provider/Drivers/OpenCodeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "../Layers/OpenCodeProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts";
import { OpenCodeRuntime } from "../opencodeRuntime.ts";
import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
import {
Expand Down Expand Up @@ -252,6 +253,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
Effect.map(([machineSnapshot, skills]) => ({
...machineSnapshot,
skills: openCodeSkillsToServerProviderSkills(skills),
inventory: {
...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY),
skills: "authoritative" as const,
},
})),
Effect.mapError(
(cause) =>
Expand Down
Loading
Loading