Skip to content

Commit efdb6c0

Browse files
committed
feat(skills): live refresh from the filesystem
- SkillsService.watchSkills merges @parcel/watcher streams over ~/.claude/skills (created if missing) and each open workspace folder's .claude/skills into a single debounced "skills changed" event stream. One failing root cannot break the others. - skills.watch tRPC subscription using the async-generator pattern, with the watcher bound alongside SkillsService in skills.module. - useSkillsWatcher subscribes once at the SkillsView boundary and invalidates every skills query on change; the 30s staleTime fallbacks are gone. Generated-By: PostHog Code Task-Id: f4e84f1a-19c9-490c-9b98-47787a7dddcf
1 parent 34baff5 commit efdb6c0

9 files changed

Lines changed: 159 additions & 8 deletions

File tree

packages/host-router/src/routers/skills.router.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,10 @@ export const skillsRouter = router({
8383
.get<SkillsService>(SKILLS_SERVICE)
8484
.deleteSkill(input.skillPath),
8585
),
86+
watch: publicProcedure.subscription(async function* (opts) {
87+
const service = opts.ctx.container.get<SkillsService>(SKILLS_SERVICE);
88+
for await (const event of service.watchSkills(opts.signal)) {
89+
yield event;
90+
}
91+
}),
8692
});

packages/ui/src/features/skills/SkillsView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ import { SkillSection, SOURCE_CONFIG } from "./SkillCard";
1616
import { SkillDetailPanel } from "./SkillDetailPanel";
1717
import { useSkillsSidebarStore } from "./skillsSidebarStore";
1818
import { useSkills } from "./useSkills";
19+
import { useSkillsWatcher } from "./useSkillsWatcher";
1920

2021
const SOURCE_ORDER: SkillSource[] = ["user", "marketplace", "repo", "bundled"];
2122

2223
export function SkillsView() {
2324
const { data: skills = [], isLoading } = useSkills();
25+
useSkillsWatcher();
2426

2527
const [selectedPath, setSelectedPath] = useState<string | null>(null);
2628
const [searchQuery, setSearchQuery] = useState("");

packages/ui/src/features/skills/useSkillContents.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,15 @@ import { useQuery } from "@tanstack/react-query";
33

44
export function useSkillContents(skillPath: string) {
55
const trpc = useHostTRPC();
6-
return useQuery(
7-
trpc.skills.contents.queryOptions({ skillPath }, { staleTime: 30_000 }),
8-
);
6+
return useQuery(trpc.skills.contents.queryOptions({ skillPath }));
97
}
108

119
export function useSkillFile(skillPath: string, filePath: string | null) {
1210
const trpc = useHostTRPC();
1311
return useQuery(
1412
trpc.skills.readFile.queryOptions(
1513
{ skillPath, filePath: filePath ?? "" },
16-
{ enabled: filePath !== null, staleTime: 30_000 },
14+
{ enabled: filePath !== null },
1715
),
1816
);
1917
}

packages/ui/src/features/skills/useSkills.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,5 @@ import { useQuery } from "@tanstack/react-query";
33

44
export function useSkills() {
55
const trpc = useHostTRPC();
6-
return useQuery(
7-
trpc.skills.list.queryOptions(undefined, { staleTime: 30_000 }),
8-
);
6+
return useQuery(trpc.skills.list.queryOptions());
97
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useHostTRPC } from "@posthog/host-router/react";
2+
import { useQueryClient } from "@tanstack/react-query";
3+
import { useSubscription } from "@trpc/tanstack-react-query";
4+
5+
/**
6+
* Invalidates every skills query when the writable skill roots change on
7+
* disk, so external edits (terminals, agent sessions) appear live.
8+
*/
9+
export function useSkillsWatcher() {
10+
const trpc = useHostTRPC();
11+
const queryClient = useQueryClient();
12+
useSubscription(
13+
trpc.skills.watch.subscriptionOptions(undefined, {
14+
onData: () => {
15+
void queryClient.invalidateQueries(trpc.skills.pathFilter());
16+
},
17+
}),
18+
);
19+
}

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

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

77+
export const skillsChangedEvent = z.object({
78+
changed: z.literal(true),
79+
});
80+
7781
export type SkillInfo = z.infer<typeof skillInfo>;
7882
export type SkillScope = z.infer<typeof skillScope>;
7983
export type CreateSkillInput = z.infer<typeof createSkillInput>;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { ContainerModule } from "inversify";
2+
import { WATCHER_SERVICE } from "../../di/tokens";
3+
import { WatcherService } from "../watcher/service";
24
import { SKILLS_SERVICE } from "./identifiers";
35
import { SkillsService } from "./skills";
46

57
export const skillsModule = new ContainerModule(({ bind }) => {
68
bind(SKILLS_SERVICE).to(SkillsService).inSingletonScope();
9+
// SkillsService watches the writable skill roots. Hosts that load this
10+
// module (Electron main) do not otherwise bind WatcherService.
11+
bind(WATCHER_SERVICE).to(WatcherService).inSingletonScope();
712
});

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it } from "vitest";
55
import type { FoldersService } from "../folders/folders";
66
import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin";
7+
import { WatcherService } from "../watcher/service";
78
import { SkillsService } from "./skills";
89

910
let root: string;
@@ -18,7 +19,7 @@ function makeService(): SkillsService {
1819
const folders = {
1920
getFolders: async () => [{ path: folderPath, name: "my-repo" }],
2021
} as unknown as FoldersService;
21-
return new SkillsService(plugin, folders);
22+
return new SkillsService(plugin, folders, new WatcherService());
2223
}
2324

2425
async function createSkill(
@@ -180,6 +181,45 @@ describe("readSkillFile", () => {
180181
});
181182
});
182183

184+
describe("watchSkillDirs", () => {
185+
it(
186+
"emits a debounced change event when a watched dir changes",
187+
{ timeout: 15_000 },
188+
async () => {
189+
const service = makeService();
190+
const controller = new AbortController();
191+
const generator = service.watchSkillDirs(
192+
[repoSkillsDir],
193+
controller.signal,
194+
);
195+
196+
const firstEvent = generator.next();
197+
// Give the native watcher a moment to attach before mutating the dir.
198+
await new Promise((r) => setTimeout(r, 500));
199+
await createSkill(repoSkillsDir, "watched-skill");
200+
201+
const result = await Promise.race([
202+
firstEvent,
203+
new Promise<never>((_, reject) =>
204+
setTimeout(
205+
() => reject(new Error("timed out waiting for change event")),
206+
10_000,
207+
),
208+
),
209+
]);
210+
expect(result).toEqual({ value: { changed: true }, done: false });
211+
212+
controller.abort();
213+
await generator.return(undefined);
214+
},
215+
);
216+
217+
it("finishes immediately with no directories", async () => {
218+
const generator = makeService().watchSkillDirs([]);
219+
expect(await generator.next()).toEqual({ value: undefined, done: true });
220+
});
221+
});
222+
183223
describe("createSkill", () => {
184224
it("scaffolds a directory with a parseable SKILL.md", async () => {
185225
const service = makeService();

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import * as fs from "node:fs";
22
import * as os from "node:os";
33
import * as path from "node:path";
44
import { inject, injectable } from "inversify";
5+
import { WATCHER_SERVICE } from "../../di/tokens";
56
import type { FoldersService } from "../folders/folders";
67
import { FOLDERS_SERVICE } from "../folders/identifiers";
78
import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers";
89
import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin";
10+
import type { WatcherService } from "../watcher/service";
911
import { parseSkillFrontmatter } from "./parse-skill-frontmatter";
1012
import type {
1113
CreateSkillInput,
@@ -22,6 +24,7 @@ import { serializeSkillMarkdown } from "./write-skill-frontmatter";
2224

2325
const MAX_SKILL_FILES = 500;
2426
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024;
27+
const SKILLS_WATCH_DEBOUNCE_MS = 300;
2528
const SKILL_DIR_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
2629
const MAX_SKILL_DIR_NAME_LENGTH = 64;
2730

@@ -45,6 +48,8 @@ export class SkillsService {
4548
private readonly plugin: PosthogPluginService,
4649
@inject(FOLDERS_SERVICE)
4750
private readonly folders: FoldersService,
51+
@inject(WATCHER_SERVICE)
52+
private readonly watcher: WatcherService,
4853
) {}
4954

5055
async listSkills(): Promise<SkillInfo[]> {
@@ -170,6 +175,68 @@ export class SkillsService {
170175
await fs.promises.rm(skillDir, { recursive: true, force: true });
171176
}
172177

178+
/**
179+
* Emits a debounced "skills changed" event whenever anything inside the
180+
* writable skill roots changes on disk (external editors, agent sessions,
181+
* `touch` from a terminal, ...).
182+
*/
183+
async *watchSkills(signal?: AbortSignal): AsyncGenerator<{ changed: true }> {
184+
const userRoot = path.join(os.homedir(), ".claude", "skills");
185+
// The user root is ours to create; repo roots are only watched if present.
186+
await fs.promises.mkdir(userRoot, { recursive: true }).catch(() => {});
187+
const folders = await this.folders.getFolders();
188+
const dirs = [
189+
userRoot,
190+
...folders.map((f) => path.join(f.path, ".claude", "skills")),
191+
].filter((dir) => fs.existsSync(dir));
192+
193+
yield* this.watchSkillDirs(dirs, signal);
194+
}
195+
196+
/** Merges watchers over the given directories into one debounced stream. */
197+
async *watchSkillDirs(
198+
dirs: string[],
199+
signal?: AbortSignal,
200+
): AsyncGenerator<{ changed: true }> {
201+
if (dirs.length === 0) return;
202+
203+
let pending = false;
204+
let finished = 0;
205+
let notify: (() => void) | undefined;
206+
const wake = () => notify?.();
207+
208+
for (const dir of dirs) {
209+
void (async () => {
210+
try {
211+
for await (const _batch of this.watcher.watch(dir, {}, signal)) {
212+
pending = true;
213+
wake();
214+
}
215+
} catch {
216+
// A failed watcher on one root must not break the others.
217+
} finally {
218+
finished++;
219+
wake();
220+
}
221+
})();
222+
}
223+
224+
while (finished < dirs.length && !signal?.aborted) {
225+
if (!pending) {
226+
await new Promise<void>((resolve) => {
227+
notify = resolve;
228+
});
229+
notify = undefined;
230+
continue;
231+
}
232+
// Collapse bursts of file events into a single notification.
233+
await delay(SKILLS_WATCH_DEBOUNCE_MS, signal);
234+
if (signal?.aborted) return;
235+
pending = false;
236+
yield { changed: true };
237+
}
238+
}
239+
173240
private async getSkillRoots(): Promise<SkillRoot[]> {
174241
const pluginPath = this.plugin.getPluginPath();
175242
const folders = await this.folders.getFolders();
@@ -273,6 +340,18 @@ function validateSkillDirName(name: string): void {
273340
}
274341
}
275342

343+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
344+
return new Promise((resolve) => {
345+
const timer = setTimeout(done, ms);
346+
function done() {
347+
signal?.removeEventListener("abort", done);
348+
clearTimeout(timer);
349+
resolve();
350+
}
351+
signal?.addEventListener("abort", done, { once: true });
352+
});
353+
}
354+
276355
function resolveSkillFilePath(skillDir: string, filePath: string): string {
277356
const resolved = path.resolve(skillDir, filePath);
278357
if (resolved === skillDir || !resolved.startsWith(skillDir + path.sep)) {

0 commit comments

Comments
 (0)