Skip to content

Commit 0e99148

Browse files
Merge pull request #34 from ScriptedAlchemy/feat/skills-library
feat(server): one skills library for every harness and account
2 parents f40c8fa + 77ebd3c commit 0e99148

27 files changed

Lines changed: 1264 additions & 22 deletions

‎apps/server/src/auth/RpcAuthorization.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ describe("RPC authorization scopes", () => {
7878
}
7979
});
8080

81+
it("lets read-only clients see skills library status but not move skill folders", () => {
82+
expect(requiredScopeForRpcMethod(WS_METHODS.serverGetSkillsLibraryStatus)).toBe(
83+
AuthOrchestrationReadScope,
84+
);
85+
expect(requiredScopeForRpcMethod(WS_METHODS.serverRefreshSkillsLibrary)).toBe(
86+
AuthSettingsWriteScope,
87+
);
88+
});
89+
8190
it("treats the project Loom as a thread read", () => {
8291
expect(requiredScopeForRpcMethod(WS_METHODS.projectReadLoomWindow)).toBe(
8392
AuthOrchestrationReadScope,

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const RPC_REQUIRED_SCOPES = {
7676
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
7777
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
7878
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope,
79+
[WS_METHODS.serverGetSkillsLibraryStatus]: AuthOrchestrationReadScope,
7980
[WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope,
8081
[WS_METHODS.providerAuthStart]: AuthProvidersManageScope,
8182
[WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope,

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export const make = Effect.gen(function* () {
245245
storageCleanupRun: true,
246246
databaseStorage: true,
247247
otlpEndpointCheck: true,
248+
skillsLibrary: true,
248249
projectWorktreeCleanup: true,
249250
settledWorktreeCleanup: true,
250251
worktreesDirectory: true,

‎apps/server/src/mcp/toolkits/core.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
5858
import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
5959
import { dispatchFailure } from "../threadAccess.ts";
6060
import * as EnvironmentHandlers from "./environment/handlers.ts";
61+
import { EnvironmentToolkit } from "./environment/tools.ts";
6162
import * as AttachmentHandlers from "./attachment/handlers.ts";
6263
import * as PeerForwarding from "../../peer/PeerForwarding.ts";
6364
import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts";
@@ -490,6 +491,7 @@ it.effect("keeps unexpected handler defects private through the production regis
490491
it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
491492
const settings = {
492493
...DEFAULT_SERVER_SETTINGS,
494+
skillsLibraryEnabled: true,
493495
privateCredential: "must-not-escape",
494496
sourceControlWritingStyle: {
495497
...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle,
@@ -499,10 +501,15 @@ it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
499501
const result = EnvironmentHandlers.preferences(settings);
500502
expect(result).not.toHaveProperty("privateCredential");
501503
expect(result).not.toHaveProperty("providers");
504+
expect(result.skillsLibraryEnabled).toBe(true);
502505
expect(result.sourceControlWritingStyle).toMatchObject({
503506
customInstructions: "🙂".repeat(4000),
504507
truncated: true,
505508
});
509+
const update = Schema.decodeUnknownSync(
510+
EnvironmentToolkit.tools.t3_environment_preferences_update.parametersSchema,
511+
);
512+
expect(update({ skillsLibraryEnabled: true })).toMatchObject({ skillsLibraryEnabled: true });
506513
});
507514

508515
it.effect("resolves reused attachment references from stored metadata", () =>

‎apps/server/src/mcp/toolkits/environment/handlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function preferences(settings: ServerSettings) {
1313
defaultThreadEnvMode,
1414
newWorktreesStartFromOrigin,
1515
enableProviderUpdateChecks,
16+
skillsLibraryEnabled,
1617
backgroundActivity,
1718
sourceControlWritingStyle,
1819
} = settings;
@@ -21,6 +22,7 @@ export function preferences(settings: ServerSettings) {
2122
defaultThreadEnvMode,
2223
newWorktreesStartFromOrigin,
2324
enableProviderUpdateChecks,
25+
skillsLibraryEnabled,
2426
backgroundActivity: { profile: backgroundActivity.profile },
2527
sourceControlWritingStyle: {
2628
...sourceControlWritingStyle,

‎apps/server/src/mcp/toolkits/environment/tools.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const PreferenceFields = {
1919
defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode,
2020
newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin,
2121
enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks,
22+
skillsLibraryEnabled: ServerSettings.fields.skillsLibraryEnabled,
2223
backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }),
2324
sourceControlWritingStyle: Schema.Struct({
2425
mode: Schema.String,
@@ -62,6 +63,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update"
6263
defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode,
6364
newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin,
6465
enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks,
66+
skillsLibraryEnabled: ServerSettingsPatch.fields.skillsLibraryEnabled,
6567
backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })),
6668
sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle,
6769
}),

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ const RPC_AGGREGATES = {
9898
[WS_METHODS.serverGetResourceTelemetryHistory]: "server",
9999
[WS_METHODS.serverRetryResourceTelemetry]: "server",
100100
[WS_METHODS.serverCheckOtlpEndpoint]: "server",
101+
[WS_METHODS.serverGetSkillsLibraryStatus]: "server",
102+
[WS_METHODS.serverRefreshSkillsLibrary]: "server",
101103
[WS_METHODS.serverPrepareTriage]: "server",
102104
[WS_METHODS.serverGetUsageSummary]: "server",
103105
[WS_METHODS.serverRefreshUsageRates]: "server",
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"claudeSystemAppendBytes": 7975,
33
"codexAdditionalContextBytes": 9701,
4-
"mcpToolListJsonBytes": 60654
4+
"mcpToolListJsonBytes": 60696
55
}

‎apps/server/src/server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as SkillsLibrary from "./skills/SkillsLibrary.ts";
12
import * as PstackPack from "./pstack/PstackPack.ts";
23
import * as HolographicMemory from "./project/knowledge/HolographicMemory.ts";
34
import * as ProjectKnowledge from "./project/knowledge/ProjectKnowledge.ts";
@@ -644,6 +645,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
644645
Layer.provideMerge(
645646
PstackPack.layer.pipe(Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry)),
646647
),
648+
Layer.provideMerge(SkillsLibrary.layer),
647649
Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),
648650
Layer.provideMerge(
649651
ConversationSummaries.layer.pipe(
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem has no lstat.
2+
import * as NodeFSP from "node:fs/promises";
3+
import * as NodeServices from "@effect/platform-node/NodeServices";
4+
import { describe, expect, it } from "@effect/vitest";
5+
import * as HostProcess from "@t3tools/shared/HostProcess";
6+
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
7+
import * as Effect from "effect/Effect";
8+
import * as FileSystem from "effect/FileSystem";
9+
import * as Layer from "effect/Layer";
10+
import * as Path from "effect/Path";
11+
import * as PubSub from "effect/PubSub";
12+
13+
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
14+
import * as ServerSettings from "../serverSettings.ts";
15+
import * as SkillsLibrary from "./SkillsLibrary.ts";
16+
17+
const setup = Effect.fn("SkillsLibrary.test.setup")(function* () {
18+
const fs = yield* FileSystem.FileSystem;
19+
const path = yield* Path.Path;
20+
const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-skills-library-" });
21+
const claude = path.join(home, ".claude", "skills");
22+
const codex = path.join(home, ".codex", "skills");
23+
const library = path.join(home, ".agents", "skills");
24+
yield* fs.makeDirectory(claude, { recursive: true });
25+
yield* fs.makeDirectory(codex, { recursive: true });
26+
const service = yield* SkillsLibrary.make.pipe(
27+
Effect.provide(ServerSettings.ServerSettingsService.layerTest()),
28+
Effect.provideService(HostProcess.HomeDirectory, home),
29+
Effect.provideService(HostProcess.Environment, { HOME: home }),
30+
);
31+
const skill = (root: string, name: string, body: string, frontmatterName = name) =>
32+
fs
33+
.makeDirectory(path.join(root, name), { recursive: true })
34+
.pipe(
35+
Effect.andThen(
36+
fs.writeFileString(
37+
path.join(root, name, "SKILL.md"),
38+
`---\nname: ${frontmatterName}\ndescription: test\n---\n${body}\n`,
39+
),
40+
),
41+
);
42+
const read = (root: string, name: string) => fs.readFileString(path.join(root, name, "SKILL.md"));
43+
const link = (root: string, name: string) =>
44+
fs.readLink(path.join(root, name)).pipe(Effect.orElseSucceed(() => undefined));
45+
return { fs, path, home, claude, codex, library, service, skill, read, link };
46+
});
47+
48+
/** Every entry under `root`, links not followed, with what a write would change. */
49+
const snapshot = (root: string) =>
50+
Effect.promise(async () => {
51+
const entries: Array<string> = [];
52+
const walk = async (directory: string) => {
53+
for (const name of (await NodeFSP.readdir(directory)).sort()) {
54+
const entry = `${directory}/${name}`;
55+
const info = await NodeFSP.lstat(entry);
56+
entries.push(`${entry} ${info.ino} ${info.mtimeMs}`);
57+
if (info.isDirectory()) await walk(entry);
58+
}
59+
};
60+
await walk(root);
61+
return entries;
62+
});
63+
64+
it.layer(NodeServices.layer)("SkillsLibrary", (it) => {
65+
describe("apply", () => {
66+
it.effect.skipIf(!symlinksSupported)(
67+
"keeps one copy of a skill two harnesses share and links it into both",
68+
() =>
69+
Effect.gen(function* () {
70+
const { fs, path, home, claude, codex, library, service, skill, read, link } =
71+
yield* setup();
72+
yield* skill(claude, "shared", "same");
73+
yield* skill(codex, "shared", "same");
74+
yield* skill(claude, "claude-only", "claude");
75+
yield* skill(path.join(claude, "synced", "org_account"), "synced-skill", "synced");
76+
yield* skill(path.join(codex, ".system"), "bundled", "bundled");
77+
// A root that already is the library must not be emptied into itself.
78+
yield* fs.makeDirectory(path.join(home, ".gemini", "config"), { recursive: true });
79+
yield* fs.makeDirectory(path.dirname(library), { recursive: true });
80+
yield* fs.makeDirectory(library);
81+
yield* fs.symlink(library, path.join(home, ".gemini", "config", "skills"));
82+
83+
yield* service.apply;
84+
85+
expect((yield* fs.readDirectory(library)).sort()).toEqual(["claude-only", "shared"]);
86+
expect(yield* read(library, "shared")).toContain("same");
87+
for (const root of [claude, codex]) {
88+
expect(yield* link(root, "shared")).toBe(path.join(library, "shared"));
89+
expect(yield* link(root, "claude-only")).toBe(path.join(library, "claude-only"));
90+
}
91+
expect(yield* read(path.join(claude, "synced", "org_account"), "synced-skill")).toContain(
92+
"synced",
93+
);
94+
expect(yield* read(path.join(codex, ".system"), "bundled")).toContain("bundled");
95+
expect(yield* fs.readDirectory(path.join(codex, ".system"))).toEqual(["bundled"]);
96+
97+
const status = yield* service.status;
98+
expect(status.libraryPath).toBe(library);
99+
expect(status.conflicts).toEqual([]);
100+
expect(status.linked).toEqual([
101+
{ driver: "claudeAgent", name: "claude-only" },
102+
{ driver: "claudeAgent", name: "shared" },
103+
{ driver: "codex", name: "claude-only" },
104+
{ driver: "codex", name: "shared" },
105+
]);
106+
107+
const before = yield* snapshot(home);
108+
yield* service.apply;
109+
expect(yield* snapshot(home)).toEqual(before);
110+
}),
111+
);
112+
113+
it.effect.skipIf(!symlinksSupported)(
114+
"moves neither copy of a skill whose copies differ and reports both paths",
115+
() =>
116+
Effect.gen(function* () {
117+
const { fs, path, home, claude, codex, library, service, skill, read, link } =
118+
yield* setup();
119+
yield* skill(claude, "review", "claude version");
120+
yield* skill(codex, "review", "codex version");
121+
yield* skill(codex, "other", "other");
122+
// One folder on a case-insensitive disk, two on a case-sensitive one.
123+
yield* skill(claude, "Deploy", "same");
124+
yield* skill(codex, "deploy", "same");
125+
// Moving it would break a link that climbs out of the skill.
126+
yield* fs.makeDirectory(path.join(claude, "linked-out"));
127+
yield* fs.writeFileString(path.join(home, ".claude", "shared.md"), "shared");
128+
yield* fs.symlink("../../shared.md", path.join(claude, "linked-out", "SKILL.md"));
129+
// Codex's equal copy would be parked at a path the user already uses.
130+
yield* skill(claude, "dup", "same");
131+
yield* skill(codex, "dup", "same");
132+
yield* skill(codex, ".dup.t3-discard", "mine");
133+
134+
yield* service.apply;
135+
136+
expect(yield* read(claude, "review")).toContain("claude version");
137+
expect(yield* read(codex, "review")).toContain("codex version");
138+
expect(yield* link(claude, "review")).toBeUndefined();
139+
expect(yield* link(claude, "other")).toBe(path.join(library, "other"));
140+
for (const [root, name] of [
141+
[claude, "Deploy"],
142+
[codex, "deploy"],
143+
[claude, "linked-out"],
144+
[claude, "dup"],
145+
[codex, "dup"],
146+
] as const) {
147+
expect(yield* link(root, name)).toBeUndefined();
148+
}
149+
expect(yield* read(claude, "linked-out")).toBe("shared");
150+
expect(yield* read(codex, ".dup.t3-discard")).toContain("mine");
151+
expect((yield* service.status).conflicts).toEqual([
152+
{ name: "Deploy", paths: [path.join(claude, "Deploy"), path.join(codex, "deploy")] },
153+
{ name: "review", paths: [path.join(claude, "review"), path.join(codex, "review")] },
154+
{
155+
name: "linked-out",
156+
paths: [path.join(claude, "linked-out"), path.join(claude, "linked-out", "SKILL.md")],
157+
reason: "outsideLink",
158+
},
159+
{
160+
name: "dup",
161+
paths: [path.join(codex, ".dup.t3-discard"), path.join(claude, "dup")],
162+
},
163+
]);
164+
}),
165+
);
166+
167+
it.effect.skipIf(!symlinksSupported)(
168+
"reports a frontmatter name that differs from its folder",
169+
() =>
170+
Effect.gen(function* () {
171+
const { claude, service, skill } = yield* setup();
172+
yield* skill(claude, "deploy", "body", "ship-it");
173+
yield* skill(claude, "matching", "body");
174+
175+
expect((yield* service.status).nameMismatches).toEqual([
176+
{ name: "deploy", frontmatterName: "ship-it" },
177+
]);
178+
}),
179+
);
180+
});
181+
182+
describe("detach", () => {
183+
it.effect.skipIf(!symlinksSupported)(
184+
"restores real folders and removes only links into the library",
185+
() =>
186+
Effect.gen(function* () {
187+
const { fs, path, home, claude, codex, library, service, skill, read, link } =
188+
yield* setup();
189+
yield* skill(claude, "hello", "hello");
190+
yield* skill(claude, "kept", "kept");
191+
yield* skill(codex, "codex-only", "codex");
192+
yield* service.apply;
193+
const elsewhere = path.join(home, "dev", "skills");
194+
yield* skill(elsewhere, "mine", "mine");
195+
yield* fs.symlink(path.join(elsewhere, "mine"), path.join(claude, "mine"));
196+
// Points into the library under another name, so it is the user's link.
197+
yield* fs.symlink(path.join(library, "hello"), path.join(claude, "alias"));
198+
// A folder at the staging path that is not a copy of what it would restore.
199+
yield* skill(claude, ".kept.t3-restore", "mine");
200+
// A crash after unlinking, before the rename, leaves only the staged copy.
201+
yield* fs.remove(path.join(claude, "codex-only"));
202+
yield* fs.copy(
203+
path.join(library, "codex-only"),
204+
path.join(claude, ".codex-only.t3-restore"),
205+
);
206+
207+
yield* service.detach;
208+
yield* service.detach;
209+
210+
for (const name of ["hello", "codex-only"]) {
211+
expect(yield* link(claude, name)).toBeUndefined();
212+
expect(yield* read(claude, name)).toBe(yield* read(library, name));
213+
}
214+
expect(yield* link(claude, "mine")).toBe(path.join(elsewhere, "mine"));
215+
expect(yield* link(claude, "alias")).toBe(path.join(library, "hello"));
216+
expect(yield* link(claude, "kept")).toBe(path.join(library, "kept"));
217+
expect(yield* read(claude, ".kept.t3-restore")).toContain("mine");
218+
expect((yield* fs.readDirectory(claude)).sort()).toEqual([
219+
".kept.t3-restore",
220+
"alias",
221+
"codex-only",
222+
"hello",
223+
"kept",
224+
"mine",
225+
]);
226+
// Codex reads the library itself, so it gets no second copy.
227+
expect(yield* fs.readDirectory(codex)).toEqual([]);
228+
expect((yield* fs.readDirectory(library)).sort()).toEqual([
229+
"codex-only",
230+
"hello",
231+
"kept",
232+
]);
233+
const status = yield* service.status;
234+
expect(status.linked).toEqual([{ driver: "claudeAgent", name: "kept" }]);
235+
expect(status.conflicts).toEqual([
236+
{
237+
name: "kept",
238+
paths: [path.join(claude, ".kept.t3-restore"), path.join(claude, "kept")],
239+
},
240+
]);
241+
}),
242+
);
243+
244+
it.effect.skipIf(!symlinksSupported)(
245+
"finishes at startup a detach a restart interrupted while the switch is off",
246+
() =>
247+
Effect.gen(function* () {
248+
const { fs, path, home, claude, library, service, skill, read, link } = yield* setup();
249+
yield* skill(claude, "hello", "hello");
250+
yield* service.apply;
251+
yield* fs.remove(path.join(claude, "hello"));
252+
yield* fs.copy(path.join(library, "hello"), path.join(claude, ".hello.t3-restore"));
253+
254+
yield* Layer.build(SkillsLibrary.layer).pipe(
255+
Effect.provide(
256+
Layer.mergeAll(
257+
ServerSettings.ServerSettingsService.layerTest(),
258+
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
259+
listInstances: Effect.succeed([]),
260+
subscribeChanges: PubSub.unbounded<void>().pipe(Effect.flatMap(PubSub.subscribe)),
261+
}),
262+
),
263+
),
264+
Effect.provideService(HostProcess.HomeDirectory, home),
265+
Effect.provideService(HostProcess.Environment, { HOME: home }),
266+
);
267+
268+
expect(yield* link(claude, "hello")).toBeUndefined();
269+
expect(yield* read(claude, "hello")).toBe(yield* read(library, "hello"));
270+
expect(yield* fs.readDirectory(claude)).toEqual(["hello"]);
271+
}),
272+
);
273+
});
274+
});

0 commit comments

Comments
 (0)