Skip to content

Commit c6ada14

Browse files
feat(server): one skills library for every harness
Each harness keeps its own user skill folder, so the same skill lived as separate copies in ~/.claude/skills and ~/.codex/skills and an account's view depended on its brand. A server-scoped switch (Settings > Providers > Skills library, off by default) keeps skills in ~/.agents/skills, which Codex, Cursor, OpenCode and others read natively, and links each one into Claude's, Codex's and Antigravity's folders. SkillsLibrary migrates real folders through the home overlay (identical copies are kept once; differing copies stay put and are reported with both paths), leaves Claude's synced/ and Codex's .system/ alone, and detaches by copying linked skills back. It applies at server start, on the switch, and when a Claude or Codex instance appears. The overlay's directory Move now drops a source whose content equals the shared copy. Implemented by Claude Opus 5.5 via T3 Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e89a952 commit c6ada14

21 files changed

Lines changed: 941 additions & 2 deletions

File tree

‎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/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",

‎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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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 Path from "effect/Path";
10+
11+
import * as ServerSettings from "../serverSettings.ts";
12+
import * as SkillsLibrary from "./SkillsLibrary.ts";
13+
14+
const setup = Effect.fn("SkillsLibrary.test.setup")(function* () {
15+
const fs = yield* FileSystem.FileSystem;
16+
const path = yield* Path.Path;
17+
const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-skills-library-" });
18+
const claude = path.join(home, ".claude", "skills");
19+
const codex = path.join(home, ".codex", "skills");
20+
const library = path.join(home, ".agents", "skills");
21+
yield* fs.makeDirectory(claude, { recursive: true });
22+
yield* fs.makeDirectory(codex, { recursive: true });
23+
const service = yield* SkillsLibrary.make.pipe(
24+
Effect.provide(ServerSettings.ServerSettingsService.layerTest()),
25+
Effect.provideService(HostProcess.HomeDirectory, home),
26+
Effect.provideService(HostProcess.Environment, { HOME: home }),
27+
);
28+
const skill = (root: string, name: string, body: string, frontmatterName = name) =>
29+
fs
30+
.makeDirectory(path.join(root, name), { recursive: true })
31+
.pipe(
32+
Effect.andThen(
33+
fs.writeFileString(
34+
path.join(root, name, "SKILL.md"),
35+
`---\nname: ${frontmatterName}\ndescription: test\n---\n${body}\n`,
36+
),
37+
),
38+
);
39+
const read = (root: string, name: string) => fs.readFileString(path.join(root, name, "SKILL.md"));
40+
const link = (root: string, name: string) =>
41+
fs.readLink(path.join(root, name)).pipe(Effect.orElseSucceed(() => undefined));
42+
return { fs, path, home, claude, codex, library, service, skill, read, link };
43+
});
44+
45+
/** Every entry under `root`, links not followed, with what a write would change. */
46+
const snapshot = (root: string) =>
47+
Effect.promise(async () => {
48+
const entries: Array<string> = [];
49+
const walk = async (directory: string) => {
50+
for (const name of (await NodeFSP.readdir(directory)).sort()) {
51+
const entry = `${directory}/${name}`;
52+
const info = await NodeFSP.lstat(entry);
53+
entries.push(`${entry} ${info.ino} ${info.mtimeMs}`);
54+
if (info.isDirectory()) await walk(entry);
55+
}
56+
};
57+
await walk(root);
58+
return entries;
59+
});
60+
61+
it.layer(NodeServices.layer)("SkillsLibrary", (it) => {
62+
describe("apply", () => {
63+
it.effect.skipIf(!symlinksSupported)(
64+
"keeps one copy of a skill two harnesses share and links it into both",
65+
() =>
66+
Effect.gen(function* () {
67+
const { fs, path, home, claude, codex, library, service, skill, read, link } =
68+
yield* setup();
69+
yield* skill(claude, "shared", "same");
70+
yield* skill(codex, "shared", "same");
71+
yield* skill(claude, "claude-only", "claude");
72+
yield* skill(path.join(claude, "synced", "org_account"), "synced-skill", "synced");
73+
yield* skill(path.join(codex, ".system"), "bundled", "bundled");
74+
// A root that already is the library must not be emptied into itself.
75+
yield* fs.makeDirectory(path.join(home, ".gemini", "config"), { recursive: true });
76+
yield* fs.makeDirectory(path.dirname(library), { recursive: true });
77+
yield* fs.makeDirectory(library);
78+
yield* fs.symlink(library, path.join(home, ".gemini", "config", "skills"));
79+
80+
yield* service.apply;
81+
82+
expect((yield* fs.readDirectory(library)).sort()).toEqual(["claude-only", "shared"]);
83+
expect(yield* read(library, "shared")).toContain("same");
84+
for (const root of [claude, codex]) {
85+
expect(yield* link(root, "shared")).toBe(path.join(library, "shared"));
86+
expect(yield* link(root, "claude-only")).toBe(path.join(library, "claude-only"));
87+
}
88+
expect(yield* read(path.join(claude, "synced", "org_account"), "synced-skill")).toContain(
89+
"synced",
90+
);
91+
expect(yield* read(path.join(codex, ".system"), "bundled")).toContain("bundled");
92+
expect(yield* fs.readDirectory(path.join(codex, ".system"))).toEqual(["bundled"]);
93+
94+
const status = yield* service.status;
95+
expect(status.libraryPath).toBe(library);
96+
expect(status.conflicts).toEqual([]);
97+
expect(status.linked).toEqual([
98+
{ driver: "claudeAgent", name: "claude-only" },
99+
{ driver: "claudeAgent", name: "shared" },
100+
{ driver: "codex", name: "claude-only" },
101+
{ driver: "codex", name: "shared" },
102+
]);
103+
104+
const before = yield* snapshot(home);
105+
yield* service.apply;
106+
expect(yield* snapshot(home)).toEqual(before);
107+
}),
108+
);
109+
110+
it.effect.skipIf(!symlinksSupported)(
111+
"moves neither copy of a skill whose copies differ and reports both paths",
112+
() =>
113+
Effect.gen(function* () {
114+
const { path, claude, codex, library, service, skill, read, link } = yield* setup();
115+
yield* skill(claude, "review", "claude version");
116+
yield* skill(codex, "review", "codex version");
117+
yield* skill(codex, "other", "other");
118+
119+
yield* service.apply;
120+
121+
expect(yield* read(claude, "review")).toContain("claude version");
122+
expect(yield* read(codex, "review")).toContain("codex version");
123+
expect(yield* link(claude, "review")).toBeUndefined();
124+
expect(yield* link(claude, "other")).toBe(path.join(library, "other"));
125+
expect((yield* service.status).conflicts).toEqual([
126+
{ name: "review", paths: [path.join(claude, "review"), path.join(codex, "review")] },
127+
]);
128+
}),
129+
);
130+
131+
it.effect.skipIf(!symlinksSupported)(
132+
"reports a frontmatter name that differs from its folder",
133+
() =>
134+
Effect.gen(function* () {
135+
const { claude, service, skill } = yield* setup();
136+
yield* skill(claude, "deploy", "body", "ship-it");
137+
yield* skill(claude, "matching", "body");
138+
139+
expect((yield* service.status).nameMismatches).toEqual([
140+
{ name: "deploy", frontmatterName: "ship-it" },
141+
]);
142+
}),
143+
);
144+
});
145+
146+
describe("detach", () => {
147+
it.effect.skipIf(!symlinksSupported)(
148+
"restores real folders and removes only links into the library",
149+
() =>
150+
Effect.gen(function* () {
151+
const { fs, path, home, claude, codex, library, service, skill, read, link } =
152+
yield* setup();
153+
yield* skill(claude, "hello", "hello");
154+
yield* skill(codex, "codex-only", "codex");
155+
yield* service.apply;
156+
const elsewhere = path.join(home, "dev", "skills");
157+
yield* skill(elsewhere, "mine", "mine");
158+
yield* fs.symlink(path.join(elsewhere, "mine"), path.join(claude, "mine"));
159+
// A crash after unlinking, before the rename, leaves only the staged copy.
160+
yield* fs.remove(path.join(claude, "codex-only"));
161+
yield* fs.copy(
162+
path.join(library, "codex-only"),
163+
path.join(claude, ".codex-only.t3-restore"),
164+
);
165+
166+
yield* service.detach;
167+
yield* service.detach;
168+
169+
for (const name of ["hello", "codex-only"]) {
170+
expect(yield* link(claude, name)).toBeUndefined();
171+
expect(yield* read(claude, name)).toBe(yield* read(library, name));
172+
}
173+
expect(yield* link(claude, "mine")).toBe(path.join(elsewhere, "mine"));
174+
expect((yield* fs.readDirectory(claude)).sort()).toEqual(["codex-only", "hello", "mine"]);
175+
// Codex reads the library itself, so it gets no second copy.
176+
expect(yield* fs.readDirectory(codex)).toEqual([]);
177+
expect((yield* fs.readDirectory(library)).sort()).toEqual(["codex-only", "hello"]);
178+
expect((yield* service.status).linked).toEqual([]);
179+
}),
180+
);
181+
});
182+
});

0 commit comments

Comments
 (0)