1- import { existsSync , type Dirent } from 'node:fs' ;
2- import { mkdir , readdir , readFile , readlink , rm , symlink , writeFile } from 'node:fs/promises ' ;
3- import { isAbsolute , relative , resolve } from 'node:path' ;
4- import { cwd , platform } from 'node:process' ;
1+ import { readlink , symlink } from 'node:fs/promises ' ;
2+ import { findPackageJSON } from 'node:module ' ;
3+ import { dirname , isAbsolute , relative , resolve } from 'node:path' ;
4+ import { platform } from 'node:process' ;
55import { fileURLToPath , pathToFileURL } from 'node:url' ;
6+ import { NodeHfs } from '@humanfs/node' ;
67import { parse } from 'ultramatter' ;
78import type { CommandContext } from '../context.ts' ;
89
10+ const hfs = new NodeHfs ( ) ;
11+
912const SENTINEL_START = '<!-- bsh:skills -->' ;
1013const SENTINEL_END = '<!-- /bsh:skills -->' ;
1114const GITIGNORE_START = '# bsh:skills' ;
1215const GITIGNORE_END = '# /bsh:skills' ;
1316
1417export async function sync ( _ctx : CommandContext ) : Promise < void > {
15- const root = pathToFileURL ( `${ cwd ( ) } /` ) ;
16-
17- if ( await isSelf ( root ) ) {
18- console . info ( 'Skipping sync — running inside @bomb.sh/tools' ) ;
18+ const parentPkg = findParentPackage ( ) ;
19+ if ( ! parentPkg ) {
20+ console . info ( 'Skipping sync — no parent project found (running inside @bomb.sh/tools?)' ) ;
1921 return ;
2022 }
2123
22- const source = new URL ( 'node_modules/@bomb.sh/tools/skills/' , root ) ;
23- if ( ! existsSync ( source ) ) {
24- console . error ( '@bomb.sh/tools is not installed. Run `pnpm add -D @bomb.sh/tools` first.' ) ;
24+ const root = pathToFileURL ( `${ dirname ( parentPkg ) } /` ) ;
25+ const source = new URL ( '../../skills/' , import . meta. url ) ;
26+
27+ if ( ! ( await hfs . isDirectory ( source ) ) ) {
28+ console . error ( 'Could not locate bundled skills directory.' ) ;
2529 return ;
2630 }
2731
@@ -40,12 +44,15 @@ interface SkillInfo {
4044async function copySkills ( options : { source : URL ; dest : URL } ) : Promise < SkillInfo [ ] > {
4145 const { source, dest } = options ;
4246 const skills : SkillInfo [ ] = [ ] ;
43- const entries = await readdir ( source , { withFileTypes : true } ) ;
44- const keep = new Set < string > (
45- entries . filter ( ( e ) => e . isDirectory ( ) && ! e . name . startsWith ( '_' ) ) . map ( ( e ) => e . name ) ,
46- ) ;
4747
48- await mkdir ( dest , { recursive : true } ) ;
48+ const keep = new Set < string > ( ) ;
49+ for await ( const entry of hfs . list ( source ) ) {
50+ if ( entry . isDirectory && ! entry . name . startsWith ( '_' ) ) {
51+ keep . add ( entry . name ) ;
52+ }
53+ }
54+
55+ await hfs . createDirectory ( dest ) ;
4956 await pruneStaleLinks ( { dest, source, keep } ) ;
5057
5158 const destDirPath = fileURLToPath ( dest ) ;
@@ -55,14 +62,12 @@ async function copySkills(options: { source: URL; dest: URL }): Promise<SkillInf
5562 const srcDir = new URL ( `${ name } /` , source ) ;
5663 const destDir = new URL ( `${ name } /` , dest ) ;
5764
58- await rm ( destDir , { recursive : true , force : true } ) ;
65+ await hfs . deleteAll ( destDir ) ;
5966
6067 const target = relative ( destDirPath , fileURLToPath ( srcDir ) ) ;
6168 await symlink ( target , fileURLToPath ( destDir ) , linkType ) ;
6269
63- const skillMd = new URL ( 'SKILL.md' , srcDir ) ;
64-
65- const content = await safeRead ( skillMd ) ;
70+ const content = await hfs . text ( new URL ( 'SKILL.md' , srcDir ) ) ;
6671 if ( content ) {
6772 const frontmatter = parseFrontmatter ( content ) ;
6873 if ( frontmatter ) {
@@ -80,26 +85,21 @@ async function pruneStaleLinks(options: {
8085 keep : Set < string > ;
8186} ) : Promise < void > {
8287 const { dest, source, keep } = options ;
88+ if ( ! ( await hfs . isDirectory ( dest ) ) ) return ;
89+
8390 const destPath = fileURLToPath ( dest ) ;
8491 const sourcePath = fileURLToPath ( source ) ;
8592
86- let entries : Dirent [ ] ;
87- try {
88- entries = await readdir ( dest , { withFileTypes : true } ) ;
89- } catch {
90- return ;
91- }
92-
93- for ( const entry of entries ) {
94- if ( ! entry . isSymbolicLink ( ) ) continue ;
93+ for await ( const entry of hfs . list ( dest ) ) {
94+ if ( ! entry . isSymlink ) continue ;
9595 if ( keep . has ( entry . name ) ) continue ;
9696
9797 const linkPath = fileURLToPath ( new URL ( entry . name , dest ) ) ;
9898 try {
9999 const target = await readlink ( linkPath ) ;
100100 const absTarget = isAbsolute ( target ) ? target : resolve ( destPath , target ) ;
101101 if ( absTarget . startsWith ( sourcePath ) ) {
102- await rm ( linkPath , { recursive : true , force : true } ) ;
102+ await hfs . deleteAll ( linkPath ) ;
103103 }
104104 } catch {
105105 // ignore unreadable links
@@ -110,7 +110,7 @@ async function pruneStaleLinks(options: {
110110async function updateGitignore ( options : { root : URL ; skills : SkillInfo [ ] } ) : Promise < void > {
111111 const { root, skills } = options ;
112112 const gitignorePath = new URL ( '.gitignore' , root ) ;
113- let content = ( await safeRead ( gitignorePath ) ) ?? '' ;
113+ let content = ( await hfs . text ( gitignorePath ) ) ?? '' ;
114114
115115 const lines = skills . map ( ( s ) => `skills/${ s . name } /` ) ;
116116 const section = [ GITIGNORE_START , ...lines , GITIGNORE_END ] . join ( '\n' ) ;
@@ -125,13 +125,13 @@ async function updateGitignore(options: { root: URL; skills: SkillInfo[] }): Pro
125125 content = content + suffix + '\n' + section + '\n' ;
126126 }
127127
128- await writeFile ( gitignorePath , content , 'utf8' ) ;
128+ await hfs . write ( gitignorePath , content ) ;
129129}
130130
131131async function updateAgentsMd ( options : { root : URL ; skills : SkillInfo [ ] } ) : Promise < void > {
132132 const { root, skills } = options ;
133133 const agentsPath = new URL ( 'AGENTS.md' , root ) ;
134- let content = ( await safeRead ( agentsPath ) ) ?? '' ;
134+ let content = ( await hfs . text ( agentsPath ) ) ?? '' ;
135135
136136 const lines = skills . map ( ( s ) => {
137137 const desc = s . description . split ( '.' ) [ 0 ] ?. trim ( ) ;
@@ -158,7 +158,7 @@ async function updateAgentsMd(options: { root: URL; skills: SkillInfo[] }): Prom
158158 content = content + suffix + '\n' + section + '\n' ;
159159 }
160160
161- await writeFile ( agentsPath , content , 'utf8' ) ;
161+ await hfs . write ( agentsPath , content ) ;
162162}
163163
164164function parseFrontmatter ( content : string ) : SkillInfo | undefined {
@@ -171,18 +171,16 @@ function parseFrontmatter(content: string): SkillInfo | undefined {
171171 return { name, description } ;
172172}
173173
174- async function isSelf ( root : URL ) : Promise < boolean > {
175- const content = await safeRead ( new URL ( 'package.json' , root ) ) ;
176- if ( ! content ) return false ;
177- const pkg = JSON . parse ( content ) as { name ?: string } ;
178- return pkg . name === '@bomb.sh/tools' ;
179- }
174+ function findParentPackage ( ) : string | null {
175+ const ownPkg = findPackageJSON ( import . meta. url ) ;
176+ if ( ! ownPkg ) return null ;
180177
181- async function safeRead ( url : URL ) : Promise < string | null > {
182- try {
183- return await readFile ( url , 'utf8' ) ;
184- } catch ( err ) {
185- if ( err && typeof err === 'object' && 'code' in err && err . code === 'ENOENT' ) return null ;
186- throw err ;
178+ let cursor = dirname ( dirname ( ownPkg ) ) ;
179+ while ( cursor !== dirname ( cursor ) ) {
180+ const candidate = findPackageJSON ( pathToFileURL ( ` ${ cursor } /` ) ) ;
181+ if ( ! candidate ) return null ;
182+ if ( candidate !== ownPkg ) return candidate ;
183+ cursor = dirname ( dirname ( candidate ) ) ;
187184 }
185+ return null ;
188186}
0 commit comments