@@ -2,10 +2,12 @@ import * as fs from "node:fs";
22import * as os from "node:os" ;
33import * as path from "node:path" ;
44import { inject , injectable } from "inversify" ;
5+ import { WATCHER_SERVICE } from "../../di/tokens" ;
56import type { FoldersService } from "../folders/folders" ;
67import { FOLDERS_SERVICE } from "../folders/identifiers" ;
78import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers" ;
89import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin" ;
10+ import type { WatcherService } from "../watcher/service" ;
911import { parseSkillFrontmatter } from "./parse-skill-frontmatter" ;
1012import type {
1113 CreateSkillInput ,
@@ -22,6 +24,7 @@ import { serializeSkillMarkdown } from "./write-skill-frontmatter";
2224
2325const MAX_SKILL_FILES = 500 ;
2426const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024 ;
27+ const SKILLS_WATCH_DEBOUNCE_MS = 300 ;
2528const SKILL_DIR_NAME_PATTERN = / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 . _ - ] * $ / ;
2629const 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+
276355function 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