Skip to content

Commit a78c30d

Browse files
committed
feat: add shared skills settings
1 parent cc2ab00 commit a78c30d

12 files changed

Lines changed: 2690 additions & 440 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import os from "node:os";
2+
import path from "node:path";
3+
import { promises as fs } from "node:fs";
4+
5+
import { afterEach, describe, expect, it } from "vitest";
6+
7+
import {
8+
getSharedSkillsState,
9+
initializeSharedSkills,
10+
setSharedSkillEnabled,
11+
uninstallSharedSkill,
12+
} from "./sharedSkills";
13+
14+
const tempDirectories: string[] = [];
15+
const originalHome = process.env.HOME;
16+
const originalUserProfile = process.env.USERPROFILE;
17+
18+
async function makeTempDir(prefix: string): Promise<string> {
19+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
20+
tempDirectories.push(dir);
21+
return dir;
22+
}
23+
24+
async function writeSkill(rootPath: string, name: string, description = `${name} description`) {
25+
const skillPath = path.join(rootPath, name);
26+
await fs.mkdir(skillPath, { recursive: true });
27+
await fs.writeFile(
28+
path.join(skillPath, "SKILL.md"),
29+
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`,
30+
"utf8",
31+
);
32+
return skillPath;
33+
}
34+
35+
async function writeSystemSkill(
36+
codexHomePath: string,
37+
name: string,
38+
description = `${name} description`,
39+
) {
40+
const systemRoot = path.join(codexHomePath, "skills", ".system");
41+
await fs.mkdir(systemRoot, { recursive: true });
42+
await fs.writeFile(path.join(systemRoot, ".codex-system-skills.marker"), "marker\n", "utf8");
43+
return writeSkill(systemRoot, name, description);
44+
}
45+
46+
async function withTempHome<T>(
47+
prefix: string,
48+
callback: (homePath: string) => Promise<T>,
49+
): Promise<T> {
50+
const homePath = await makeTempDir(prefix);
51+
process.env.HOME = homePath;
52+
process.env.USERPROFILE = homePath;
53+
54+
try {
55+
return await callback(homePath);
56+
} finally {
57+
process.env.HOME = originalHome;
58+
process.env.USERPROFILE = originalUserProfile;
59+
}
60+
}
61+
62+
async function useIsolatedHome(): Promise<string> {
63+
const homePath = await makeTempDir("t3-home-");
64+
process.env.HOME = homePath;
65+
process.env.USERPROFILE = homePath;
66+
return homePath;
67+
}
68+
69+
afterEach(async () => {
70+
process.env.HOME = originalHome;
71+
process.env.USERPROFILE = originalUserProfile;
72+
await Promise.all(
73+
tempDirectories.splice(0).map((dir) =>
74+
fs.rm(dir, {
75+
recursive: true,
76+
force: true,
77+
}),
78+
),
79+
);
80+
});
81+
82+
describe("sharedSkills", () => {
83+
it("initializes shared skills by moving Codex user skills and replacing them with symlinks", async () => {
84+
await useIsolatedHome();
85+
const codexHomePath = await makeTempDir("t3-codex-home-");
86+
const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills");
87+
88+
await writeSkill(path.join(codexHomePath, "skills"), "demo");
89+
90+
const result = await initializeSharedSkills({
91+
codexHomePath,
92+
sharedSkillsPath,
93+
});
94+
95+
expect(result.isInitialized).toBe(true);
96+
expect(result.skills).toEqual([
97+
expect.objectContaining({
98+
name: "demo",
99+
status: "managed",
100+
codexPathExists: true,
101+
sharedPathExists: true,
102+
symlinkedToSharedPath: true,
103+
}),
104+
]);
105+
106+
const codexSkillPath = path.join(codexHomePath, "skills", "demo");
107+
const codexStat = await fs.lstat(codexSkillPath);
108+
expect(codexStat.isSymbolicLink()).toBe(true);
109+
expect(await fs.realpath(codexSkillPath)).toBe(
110+
await fs.realpath(path.join(sharedSkillsPath, "demo")),
111+
);
112+
await expect(fs.stat(path.join(sharedSkillsPath, "demo", "SKILL.md"))).resolves.toBeDefined();
113+
});
114+
115+
it("surfaces newly discovered skills after initialization until they are explicitly moved", async () => {
116+
await useIsolatedHome();
117+
const codexHomePath = await makeTempDir("t3-codex-home-");
118+
const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills");
119+
120+
await initializeSharedSkills({
121+
codexHomePath,
122+
sharedSkillsPath,
123+
});
124+
125+
await writeSkill(path.join(codexHomePath, "skills"), "later");
126+
127+
const result = await getSharedSkillsState({
128+
codexHomePath,
129+
sharedSkillsPath,
130+
});
131+
132+
expect(result.skills).toEqual([
133+
expect.objectContaining({
134+
name: "later",
135+
status: "needs-migration",
136+
codexPathExists: true,
137+
sharedPathExists: false,
138+
symlinkedToSharedPath: false,
139+
}),
140+
]);
141+
142+
const movedState = await initializeSharedSkills({
143+
codexHomePath,
144+
sharedSkillsPath,
145+
});
146+
147+
expect(movedState.skills).toEqual([
148+
expect.objectContaining({
149+
name: "later",
150+
status: "managed",
151+
codexPathExists: true,
152+
sharedPathExists: true,
153+
symlinkedToSharedPath: true,
154+
}),
155+
]);
156+
});
157+
158+
it("initializes nested Codex skills such as .system skills", async () => {
159+
await useIsolatedHome();
160+
const codexHomePath = await makeTempDir("t3-codex-home-");
161+
const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills");
162+
163+
await writeSystemSkill(codexHomePath, "skill-creator", "system skill");
164+
165+
const result = await initializeSharedSkills({
166+
codexHomePath,
167+
sharedSkillsPath,
168+
});
169+
170+
expect(result.skills).toEqual([
171+
expect.objectContaining({
172+
name: ".system/skill-creator",
173+
status: "managed",
174+
enabled: true,
175+
codexPathExists: true,
176+
sharedPathExists: true,
177+
symlinkedToSharedPath: true,
178+
}),
179+
]);
180+
181+
const codexSkillPath = path.join(codexHomePath, "skills", ".system", "skill-creator");
182+
expect(await fs.realpath(codexSkillPath)).toBe(
183+
await fs.realpath(path.join(sharedSkillsPath, ".system", "skill-creator")),
184+
);
185+
await expect(
186+
fs.readFile(path.join(codexHomePath, "skills", ".system", ".codex-system-skills.marker")),
187+
).resolves.toBeDefined();
188+
});
189+
190+
it("finds and initializes user-installed skills from ~/.agents/skills", async () => {
191+
await withTempHome("t3-home-", async (homePath) => {
192+
const codexHomePath = path.join(homePath, ".codex");
193+
const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills");
194+
195+
await writeSkill(path.join(homePath, ".agents", "skills"), "agent-browser");
196+
197+
const beforeInit = await getSharedSkillsState({
198+
codexHomePath,
199+
sharedSkillsPath,
200+
});
201+
202+
expect(beforeInit.skills).toEqual([
203+
expect.objectContaining({
204+
name: "agent-browser",
205+
status: "needs-migration",
206+
enabled: true,
207+
codexPathExists: true,
208+
sharedPathExists: false,
209+
}),
210+
]);
211+
212+
const initialized = await initializeSharedSkills({
213+
codexHomePath,
214+
sharedSkillsPath,
215+
});
216+
217+
expect(initialized.skills).toEqual([
218+
expect.objectContaining({
219+
name: "agent-browser",
220+
status: "managed",
221+
enabled: true,
222+
codexPathExists: true,
223+
sharedPathExists: true,
224+
symlinkedToSharedPath: true,
225+
}),
226+
]);
227+
228+
expect(await fs.realpath(path.join(homePath, ".agents", "skills", "agent-browser"))).toBe(
229+
await fs.realpath(path.join(sharedSkillsPath, "agent-browser")),
230+
);
231+
});
232+
});
233+
234+
it("surfaces broken harness skill symlinks after initialization", async () => {
235+
await withTempHome("t3-home-", async (homePath) => {
236+
const codexHomePath = path.join(homePath, ".codex");
237+
const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills");
238+
239+
await initializeSharedSkills({
240+
codexHomePath,
241+
sharedSkillsPath,
242+
});
243+
244+
const agentsSkillsPath = path.join(homePath, ".agents", "skills");
245+
await fs.mkdir(agentsSkillsPath, { recursive: true });
246+
await fs.symlink(
247+
path.join(homePath, "missing-skill"),
248+
path.join(agentsSkillsPath, "agent-browser"),
249+
"dir",
250+
);
251+
252+
const state = await getSharedSkillsState({
253+
codexHomePath,
254+
sharedSkillsPath,
255+
});
256+
257+
expect(state.skills).toEqual([
258+
expect.objectContaining({
259+
name: "agent-browser",
260+
status: "broken-link",
261+
enabled: false,
262+
codexPathExists: true,
263+
sharedPathExists: false,
264+
}),
265+
]);
266+
expect(state.warnings).toContain(
267+
"Skill 'agent-browser' points to a missing directory and could not be migrated. Restore or reinstall it, then click Move skills.",
268+
);
269+
});
270+
});
271+
272+
it("keeps disabled skills hidden from Codex across refreshes", async () => {
273+
await useIsolatedHome();
274+
const codexHomePath = await makeTempDir("t3-codex-home-");
275+
const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills");
276+
277+
await writeSkill(path.join(codexHomePath, "skills"), "demo");
278+
await initializeSharedSkills({
279+
codexHomePath,
280+
sharedSkillsPath,
281+
});
282+
283+
const disabledState = await setSharedSkillEnabled({
284+
codexHomePath,
285+
sharedSkillsPath,
286+
skillName: "demo",
287+
enabled: false,
288+
});
289+
290+
expect(disabledState.skills).toEqual([
291+
expect.objectContaining({
292+
name: "demo",
293+
enabled: false,
294+
status: "needs-link",
295+
codexPathExists: false,
296+
sharedPathExists: true,
297+
}),
298+
]);
299+
300+
await expect(fs.lstat(path.join(codexHomePath, "skills", "demo"))).rejects.toMatchObject({
301+
code: "ENOENT",
302+
});
303+
304+
const refreshedState = await getSharedSkillsState({
305+
codexHomePath,
306+
sharedSkillsPath,
307+
});
308+
309+
expect(refreshedState.skills).toEqual([
310+
expect.objectContaining({
311+
name: "demo",
312+
enabled: false,
313+
status: "needs-link",
314+
codexPathExists: false,
315+
}),
316+
]);
317+
});
318+
319+
it("uninstalls a managed shared skill from both locations", async () => {
320+
await useIsolatedHome();
321+
const codexHomePath = await makeTempDir("t3-codex-home-");
322+
const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills");
323+
324+
await writeSkill(path.join(codexHomePath, "skills"), "demo");
325+
await initializeSharedSkills({
326+
codexHomePath,
327+
sharedSkillsPath,
328+
});
329+
330+
const result = await uninstallSharedSkill({
331+
codexHomePath,
332+
sharedSkillsPath,
333+
skillName: "demo",
334+
});
335+
336+
expect(result.skills).toEqual([]);
337+
await expect(fs.lstat(path.join(codexHomePath, "skills", "demo"))).rejects.toMatchObject({
338+
code: "ENOENT",
339+
});
340+
await expect(fs.lstat(path.join(sharedSkillsPath, "demo"))).rejects.toMatchObject({
341+
code: "ENOENT",
342+
});
343+
});
344+
});

0 commit comments

Comments
 (0)