Skip to content
Open
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
6 changes: 6 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ describe("RPC authorization scopes", () => {
}
});

it("reads skill folders and SKILL.md text under the filesystem read scope", () => {
for (const method of [WS_METHODS.serverListSkills, WS_METHODS.serverGetSkill]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope);
}
});

it("allows relay status reads without granting relay installation access", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe(
AuthRelayReadScope,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope,
// A skill's listing and SKILL.md text are file contents, so they take the scope the other file
// reads take, not the orchestration read scope that thread readers hold.
[WS_METHODS.serverListSkills]: AuthFilesystemReadScope,
[WS_METHODS.serverGetSkill]: AuthFilesystemReadScope,
[WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope,
[WS_METHODS.providerAuthStart]: AuthProvidersManageScope,
[WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverProbe]: "server",
[WS_METHODS.serverGetConfig]: "server",
[WS_METHODS.serverRefreshProviders]: "server",
[WS_METHODS.serverListSkills]: "server",
[WS_METHODS.serverGetSkill]: "server",
[WS_METHODS.serverUpdateProvider]: "server",
[WS_METHODS.providerAuthStart]: "provider",
[WS_METHODS.providerConsumeResetCredit]: "provider",
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/provider/Drivers/AntigravitySkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,53 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
);
});

it.layer(NodeServices.layer)("Antigravity skill folders", (it) => {
// The Skills settings page reads the same folders from a shared table, so a change to the
// table that reorders or adds a folder would change what the `$` picker offers.
it.effect("reads the home and project folders in a fixed, interleaved order", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const input = yield* makeWorkspace();
const folders = [
{ label: "home config", base: input.userHome, folder: ".gemini/config/skills" },
{ label: "project .gemini", base: input.cwd, folder: ".gemini/skills" },
{ label: "home cli", base: input.userHome, folder: ".gemini/antigravity-cli/skills" },
{ label: "project .agents", base: input.cwd, folder: ".agents/skills" },
{ label: "project .agent", base: input.cwd, folder: ".agent/skills" },
];
const ignored = [
{ base: input.userHome, folder: ".agents/skills" },
{ base: input.cwd, folder: ".claude/skills" },
{ base: input.cwd, folder: ".codex/skills" },
];
for (const { label, base, folder } of folders) {
yield* writeSkill(
path.join(base, folder, "probe"),
`---\nname: probe\ndescription: ${label}\n---\n`,
);
}
for (const { base, folder } of ignored) {
yield* writeSkill(
path.join(base, folder, "probe"),
"---\nname: probe\ndescription: ignored\n---\n",
);
}

// Each folder wins until its skill is removed, so the order is the folders' order.
for (const { label, base, folder } of folders) {
const found = yield* discoverAntigravitySkills(input);
assert.deepEqual(
found.map((skill) => [skill.name, skill.description]),
[["probe", label]],
);
yield* fileSystem.remove(path.join(base, folder, "probe"), { recursive: true });
}
assert.deepEqual(yield* discoverAntigravitySkills(input), []);
}),
);
});

it("resolves the home the agent expands ~ against", () => {
assert.equal(
resolveAntigravityUserHome(
Expand Down
29 changes: 14 additions & 15 deletions apps/server/src/provider/Drivers/AntigravitySkills.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ServerProviderSkill } from "@t3tools/contracts";
import { ProviderDriverKind, type ServerProviderSkill } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
Expand All @@ -7,6 +7,13 @@ import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { parse as parseYamlDocument } from "yaml";

import {
ANTIGRAVITY_USER_SKILL_SUBFOLDERS,
skillRootsFor,
} from "@t3tools/provider-core/server/AgentSkillFolders";

const ANTIGRAVITY_DRIVER = ProviderDriverKind.make("antigravity");

/**
* The home directory the agent expands `~` against, matching Python's
* `os.path.expanduser` in the launch environment T3 hands the process:
Expand Down Expand Up @@ -41,10 +48,8 @@ export function antigravityUserSkillDirectories(
path: Path.Path,
geminiHome: string,
): readonly [configSkills: string, cliSkills: string] {
return [
path.join(geminiHome, "config", "skills"),
path.join(geminiHome, "antigravity-cli", "skills"),
];
const [configSkills, cliSkills] = ANTIGRAVITY_USER_SKILL_SUBFOLDERS;
return [path.join(geminiHome, configSkills), path.join(geminiHome, cliSkills)];
}

const MAX_SKILL_BYTES = 1_000_000;
Expand Down Expand Up @@ -166,17 +171,11 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(
> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const [configSkills, cliSkills] = antigravityUserSkillDirectories(
path,
path.join(input.userHome, ".gemini"),
const roots = skillRootsFor(ANTIGRAVITY_DRIVER).map((root) =>
root.scope === "global"
? { directory: path.join(input.userHome, root.folder), scope: "user" }
: { directory: path.resolve(input.cwd, root.folder), scope: "project" },
);
const roots = [
{ directory: configSkills, scope: "user" },
{ directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" },
{ directory: cliSkills, scope: "user" },
{ directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" },
{ directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" },
];
const budget: ScanBudget = {
remainingBytes: MAX_SCAN_BYTES,
remainingEntries: MAX_SCAN_ENTRIES,
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,44 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
}),
);

// The Skills settings page reads the same folders from a shared table, so a change to the
// table that reorders or adds a folder would change what the `$` picker offers.
it.effect("reads the config folder first, then the project's .claude/skills, and no others", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
const configDir = path.join(tempDir, "claude-home");
const workspace = path.join(tempDir, "workspace");
const folders = [
{ label: "config", skills: path.join(configDir, "skills") },
{ label: "project .claude", skills: path.join(workspace, ".claude", "skills") },
];
const ignored = [".agents/skills", ".codex/skills", ".cursor/skills", ".gemini/skills"];
for (const { label, skills } of folders) {
yield* writeSkill(skills, "probe", `---\ndescription: ${label}\n---\n`);
}
for (const folder of ignored) {
yield* writeSkill(
path.join(workspace, folder),
"probe",
"---\ndescription: ignored\n---\n",
);
}

// Each folder wins until its skill is removed, so the order is the folders' order.
for (const { label, skills } of folders) {
const found = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
assert.deepEqual(
found.map((skill) => [skill.name, skill.description]),
[["probe", label]],
);
yield* fs.remove(path.join(skills, "probe"), { recursive: true });
}
assert.deepEqual(yield* discoverClaudeSkills({ homePath: configDir }, workspace), []);
}),
);

it.effect("returns an empty list when no skill roots exist", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
28 changes: 23 additions & 5 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
* @module provider/Drivers/ClaudeSkills
*/

import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
import {
ProviderDriverKind,
type ClaudeSettings,
type ServerProviderSkill,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
Expand All @@ -24,9 +28,12 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { parse as parseYamlDocument } from "yaml";

import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import { skillFoldersFor } from "@t3tools/provider-core/server/AgentSkillFolders";

type ClaudeSkillScope = "user" | "project";

const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent");

const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;

type SkillFrontmatter =
Expand Down Expand Up @@ -68,7 +75,11 @@ function parseFrontmatterBoolean(value: unknown): boolean | undefined {
}
}

function parseSkillFrontmatter(contents: string): SkillFrontmatter {
/**
* How Claude Code reads a SKILL.md header. `malformed` skills don't load there; the Skills
* settings page reads headers the same way, so it reports what Claude would skip.
*/
export function parseSkillFrontmatter(contents: string): SkillFrontmatter {
const match = FRONTMATTER_PATTERN.exec(contents);
if (!match) {
return { kind: "missing" };
Expand Down Expand Up @@ -235,7 +246,7 @@ function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverrid
}
}

const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
export const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
configDirPath: string,
cwd: string | undefined,
environment: NodeJS.ProcessEnv,
Expand Down Expand Up @@ -289,7 +300,7 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
* `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR`
* already present in the process environment, then `~/.claude`.
*/
const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* (
export const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* (
config: Pick<ClaudeSettings, "homePath">,
environment: NodeJS.ProcessEnv,
cwd?: string,
Expand Down Expand Up @@ -332,9 +343,16 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env);

// The user folder follows the config dir, which is `~/.claude` unless overridden; the project
// folder comes from the shared table.
const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
{ directory: path.join(configDirPath, "skills"), scope: "user" },
...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
...(cwd
? skillFoldersFor(CLAUDE_DRIVER, "project").map((folder) => ({
directory: path.join(cwd, folder),
scope: "project" as const,
}))
: []),
];

const skillsByName = new Map<string, ServerProviderSkill>();
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as SkillCatalog from "./skills/SkillCatalog.ts";
import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
Expand Down Expand Up @@ -576,6 +577,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
ReplayMarkers.layer,
).pipe(
// Core Services
// It checks a project's folder against ProjectService, which the next layer provides.
Layer.provideMerge(SkillCatalog.layer),
Layer.provideMerge(layerOrchestrationApplication),
Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),
Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
Expand Down
Loading
Loading