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
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListAgentSkills]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
Expand Down
196 changes: 196 additions & 0 deletions apps/server/src/provider/Drivers/AgentSkills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
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 {
discoverAgentSkills,
discoverProjectAgentSkills,
discoverUserAgentSkills,
} from "./AgentSkills.ts";

const writeSkill = Effect.fn(function* (
skillsDir: string,
directoryName: string,
contents: string,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const skillDir = path.join(skillsDir, directoryName);
yield* fs.makeDirectory(skillDir, { recursive: true });
yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents);
});

const isolatedDiscoveryOptions = (tempDir: string) => ({
homeDirectory: `${tempDir}/isolated-home`,
});

it.layer(NodeServices.layer)("discoverAgentSkills", (it) => {
it.effect("discovers user and project skills with frontmatter metadata", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" });
const workspace = path.join(tempDir, "workspace");
const agentsHome = path.join(tempDir, "agents-home");

yield* writeSkill(
path.join(agentsHome, ".agents", "skills"),
"agent-browser",
["---", "name: agent-browser", "description: Browser automation.", "---"].join("\n"),
);
yield* writeSkill(
path.join(workspace, ".agents", "skills"),
"test-t3-app",
["---", "name: test-t3-app", "description: Test the web app.", "---"].join("\n"),
);

const skills = yield* discoverAgentSkills(workspace, { homeDirectory: agentsHome });

assert.deepEqual(skills, [
{
name: "agent-browser",
path: path.join(agentsHome, ".agents", "skills", "agent-browser", "SKILL.md"),
enabled: true,
scope: "user",
description: "Browser automation.",
},
{
name: "test-t3-app",
path: path.join(workspace, ".agents", "skills", "test-t3-app", "SKILL.md"),
enabled: true,
scope: "project",
description: "Test the web app.",
},
]);
}),
);

it.effect("prefers project skills over user skills on name collisions", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" });
const workspace = path.join(tempDir, "workspace");
const agentsHome = path.join(tempDir, "agents-home");

yield* writeSkill(
path.join(agentsHome, ".agents", "skills"),
"shared-skill",
["---", "name: shared-skill", "description: User agents skill.", "---"].join("\n"),
);
yield* writeSkill(
path.join(workspace, ".agents", "skills"),
"shared-skill",
["---", "name: shared-skill", "description: Project agents skill.", "---"].join("\n"),
);

const skills = yield* discoverAgentSkills(workspace, { homeDirectory: agentsHome });

assert.equal(skills.length, 1);
assert.equal(skills[0]?.scope, "project");
assert.equal(skills[0]?.description, "Project agents skill.");
}),
);

it.effect("falls back to the directory name and skips malformed frontmatter", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" });
const agentsHome = path.join(tempDir, "agents-home");
const skillsDir = path.join(agentsHome, ".agents", "skills");

yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n");
yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n");
yield* fs.makeDirectory(skillsDir, { recursive: true });
yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill");

const skills = yield* discoverAgentSkills(undefined, { homeDirectory: agentsHome });

assert.deepEqual(
skills.map((skill) => skill.name),
["no-frontmatter"],
);
assert.equal(skills[0]?.description, undefined);
}),
);

it.effect("returns an empty list when no skill roots exist", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" });

const skills = yield* discoverAgentSkills(
path.join(tempDir, "missing-workspace"),
isolatedDiscoveryOptions(tempDir),
);

assert.deepEqual(skills, []);
}),
);

it.effect("discoverUserAgentSkills scans only the user home, not a workspace", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" });
const agentsHome = path.join(tempDir, "agents-home");
const workspace = path.join(tempDir, "workspace");

yield* writeSkill(
path.join(agentsHome, ".agents", "skills"),
"home-skill",
["---", "name: home-skill", "description: From home.", "---"].join("\n"),
);
yield* writeSkill(
path.join(workspace, ".agents", "skills"),
"workspace-skill",
["---", "name: workspace-skill", "description: From workspace.", "---"].join("\n"),
);

const skills = yield* discoverUserAgentSkills({ homeDirectory: agentsHome });

assert.deepEqual(
skills.map((skill) => skill.name),
["home-skill"],
);
assert.equal(skills[0]?.scope, "user");
}),
);

it.effect("discoverProjectAgentSkills scans only the given workspace root", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" });
const agentsHome = path.join(tempDir, "agents-home");
const workspace = path.join(tempDir, "workspace");

yield* writeSkill(
path.join(agentsHome, ".agents", "skills"),
"home-skill",
["---", "name: home-skill", "description: From home.", "---"].join("\n"),
);
yield* writeSkill(
path.join(workspace, ".agents", "skills"),
"workspace-skill",
["---", "name: workspace-skill", "description: From workspace.", "---"].join("\n"),
);

const skills = yield* discoverProjectAgentSkills(workspace);

assert.deepEqual(
skills.map((skill) => skill.name),
["workspace-skill"],
);
assert.equal(skills[0]?.scope, "project");
assert.equal(
skills[0]?.path,
path.join(workspace, ".agents", "skills", "workspace-skill", "SKILL.md"),
);
}),
);
});
149 changes: 149 additions & 0 deletions apps/server/src/provider/Drivers/AgentSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* AgentSkills — shared filesystem discovery of cross-agent skills for the `$` picker.
*
* Portable skills live under `~/.agents/skills` and `<cwd>/.agents/skills`, one
* directory per skill with a `SKILL.md` carrying YAML frontmatter. Providers
* without native skill inventory (Cursor, Grok, OpenCode, …) use this scanner;
* Claude layers vendor-specific roots (`<config>/skills`, `<cwd>/.claude/skills`)
* on the same scanner. Codex reports skills natively via its app-server.
*
* @module provider/Drivers/AgentSkills
*/
import * as NodeOS from "node:os";

import type { ServerProviderSkill } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { parse as parseYamlDocument } from "yaml";

export type FilesystemSkillScope = "user" | "project";

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

type SkillFrontmatter =
| { readonly kind: "missing" }
| { readonly kind: "malformed" }
| { readonly kind: "parsed"; readonly name?: string; readonly description?: string };

function parseSkillFrontmatter(contents: string): SkillFrontmatter {
const match = FRONTMATTER_PATTERN.exec(contents);
if (!match) {
return { kind: "missing" };
}

let parsed: unknown;
try {
parsed = parseYamlDocument(match[1] ?? "");
} catch {
return { kind: "malformed" };
}
if (typeof parsed !== "object" || parsed === null) {
return { kind: "malformed" };
}

const record = parsed as Record<string, unknown>;
const name = typeof record.name === "string" ? record.name.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
return {
kind: "parsed",
...(name ? { name } : {}),
...(description ? { description } : {}),
};
}

/**
* Scan explicit skill roots. Discovery is best-effort: unreadable roots and
* malformed entries are skipped. Later roots overwrite earlier ones on name
* collisions so project-scoped skills beat user-scoped ones.
*/
export const scanFilesystemSkillRoots = Effect.fn("scanFilesystemSkillRoots")(function* (
roots: ReadonlyArray<{ directory: string; scope: FilesystemSkillScope }>,
): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const skillsByName = new Map<string, ServerProviderSkill>();

for (const root of roots) {
const entries = yield* fileSystem
.readDirectory(root.directory)
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));

for (const entry of [...entries].sort()) {
const skillPath = path.join(root.directory, entry, "SKILL.md");
const contents = yield* fileSystem
.readFileString(skillPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) {
continue;
}

const frontmatter = parseSkillFrontmatter(contents);
if (frontmatter.kind === "malformed") {
continue;
}

const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim();
if (!name) {
continue;
}

skillsByName.set(name, {
name,
path: skillPath,
enabled: true,
scope: root.scope,
...(frontmatter.kind === "parsed" && frontmatter.description
? { description: frontmatter.description }
: {}),
});
}
}

return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
});

/**
* Enumerate portable skills under the user home only (`~/.agents/skills`).
* Use this for environment-level (project-agnostic) snapshots; pair with
* `discoverProjectAgentSkills` for per-workspace resolution.
*/
export const discoverUserAgentSkills = Effect.fn("discoverUserAgentSkills")(function* (options?: {
readonly homeDirectory?: string;
}): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
const path = yield* Path.Path;
const homeDirectory = options?.homeDirectory ?? NodeOS.homedir();
return yield* scanFilesystemSkillRoots([
{ directory: path.join(homeDirectory, ".agents", "skills"), scope: "user" },
]);
});

/**
* Enumerate portable skills under a single workspace root only
* (`<workspaceRoot>/.agents/skills`). Resolve this per active project so a
* project's skills follow the project, not the server's launch directory.
*/
export const discoverProjectAgentSkills = Effect.fn("discoverProjectAgentSkills")(function* (
workspaceRoot: string,
): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
const path = yield* Path.Path;
return yield* scanFilesystemSkillRoots([
{ directory: path.join(workspaceRoot, ".agents", "skills"), scope: "project" },
]);
});

/**
* Enumerate portable skills from the user home and optional workspace cwd.
*/
export const discoverAgentSkills = Effect.fn("discoverAgentSkills")(function* (
cwd?: string,
options?: { readonly homeDirectory?: string },
): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
const path = yield* Path.Path;
const homeDirectory = options?.homeDirectory ?? NodeOS.homedir();

return yield* scanFilesystemSkillRoots([
{ directory: path.join(homeDirectory, ".agents", "skills"), scope: "user" },
...(cwd ? [{ directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }] : []),
]);
});
Loading
Loading