Skip to content

Commit 1e80286

Browse files
committed
fix(skills): watch skill roots that appear mid-session, drop unused schema
Review feedback on #2606: watchSkillDirs now polls directories that do not exist yet (e.g. a repo .claude/skills created after the watch started) and attaches a watcher once they appear; the unused skillsChangedEvent schema is removed (tRPC output validation does not apply to async-generator subscriptions in our version). Generated-By: PostHog Code Task-Id: f4e84f1a-19c9-490c-9b98-47787a7dddcf
1 parent efdb6c0 commit 1e80286

3 files changed

Lines changed: 60 additions & 7 deletions

File tree

packages/workspace-server/src/services/skills/schemas.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,6 @@ export const deleteSkillInput = z.object({
7474
skillPath: z.string(),
7575
});
7676

77-
export const skillsChangedEvent = z.object({
78-
changed: z.literal(true),
79-
});
80-
8177
export type SkillInfo = z.infer<typeof skillInfo>;
8278
export type SkillScope = z.infer<typeof skillScope>;
8379
export type CreateSkillInput = z.infer<typeof createSkillInput>;

packages/workspace-server/src/services/skills/skills.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,35 @@ describe("watchSkillDirs", () => {
218218
const generator = makeService().watchSkillDirs([]);
219219
expect(await generator.next()).toEqual({ value: undefined, done: true });
220220
});
221+
222+
it(
223+
"picks up a skills dir created after the watch starts",
224+
{ timeout: 15_000 },
225+
async () => {
226+
const service = makeService();
227+
const controller = new AbortController();
228+
const lateDir = path.join(root, "late-repo", ".claude", "skills");
229+
const generator = service.watchSkillDirs([lateDir], controller.signal);
230+
231+
const firstEvent = generator.next();
232+
await new Promise((r) => setTimeout(r, 100));
233+
await mkdir(lateDir, { recursive: true });
234+
235+
const result = await Promise.race([
236+
firstEvent,
237+
new Promise<never>((_, reject) =>
238+
setTimeout(
239+
() => reject(new Error("timed out waiting for change event")),
240+
10_000,
241+
),
242+
),
243+
]);
244+
expect(result).toEqual({ value: { changed: true }, done: false });
245+
246+
controller.abort();
247+
await generator.return(undefined);
248+
},
249+
);
221250
});
222251

223252
describe("createSkill", () => {

packages/workspace-server/src/services/skills/skills.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { serializeSkillMarkdown } from "./write-skill-frontmatter";
2525
const MAX_SKILL_FILES = 500;
2626
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024;
2727
const SKILLS_WATCH_DEBOUNCE_MS = 300;
28+
const MISSING_DIR_POLL_MS = 2000;
2829
const SKILL_DIR_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
2930
const MAX_SKILL_DIR_NAME_LENGTH = 64;
3031

@@ -182,18 +183,23 @@ export class SkillsService {
182183
*/
183184
async *watchSkills(signal?: AbortSignal): AsyncGenerator<{ changed: true }> {
184185
const userRoot = path.join(os.homedir(), ".claude", "skills");
185-
// The user root is ours to create; repo roots are only watched if present.
186+
// The user root is ours to create; repo roots are watched as soon as
187+
// they appear (watchSkillDirs polls for dirs that don't exist yet).
186188
await fs.promises.mkdir(userRoot, { recursive: true }).catch(() => {});
187189
const folders = await this.folders.getFolders();
188190
const dirs = [
189191
userRoot,
190192
...folders.map((f) => path.join(f.path, ".claude", "skills")),
191-
].filter((dir) => fs.existsSync(dir));
193+
];
192194

193195
yield* this.watchSkillDirs(dirs, signal);
194196
}
195197

196-
/** Merges watchers over the given directories into one debounced stream. */
198+
/**
199+
* Merges watchers over the given directories into one debounced stream.
200+
* Directories that don't exist yet (e.g. a repo's `.claude/skills` created
201+
* mid-session) are polled and picked up once they appear.
202+
*/
197203
async *watchSkillDirs(
198204
dirs: string[],
199205
signal?: AbortSignal,
@@ -208,6 +214,12 @@ export class SkillsService {
208214
for (const dir of dirs) {
209215
void (async () => {
210216
try {
217+
if (!(await dirExists(dir))) {
218+
if (!(await waitForDir(dir, signal))) return;
219+
// The new root may already contain skills — that's a change too.
220+
pending = true;
221+
wake();
222+
}
211223
for await (const _batch of this.watcher.watch(dir, {}, signal)) {
212224
pending = true;
213225
wake();
@@ -340,6 +352,22 @@ function validateSkillDirName(name: string): void {
340352
}
341353
}
342354

355+
function dirExists(dir: string): Promise<boolean> {
356+
return fs.promises
357+
.access(dir)
358+
.then(() => true)
359+
.catch(() => false);
360+
}
361+
362+
/** Polls until the directory exists. Resolves false if aborted first. */
363+
async function waitForDir(dir: string, signal?: AbortSignal): Promise<boolean> {
364+
while (!signal?.aborted) {
365+
if (await dirExists(dir)) return true;
366+
await delay(MISSING_DIR_POLL_MS, signal);
367+
}
368+
return false;
369+
}
370+
343371
function delay(ms: number, signal?: AbortSignal): Promise<void> {
344372
return new Promise((resolve) => {
345373
const timer = setTimeout(done, ms);

0 commit comments

Comments
 (0)