htmlboard 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js ADDED
@@ -0,0 +1,529 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const CLIENT_DIR = path.join(__dirname, 'client');
10
+ const { version: VERSION } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
11
+
12
+ // Supported MIME types
13
+ const MIME_TYPES = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.htm': 'text/html; charset=utf-8',
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.js': 'application/javascript; charset=utf-8',
18
+ '.mjs': 'application/javascript; charset=utf-8',
19
+ '.json': 'application/json; charset=utf-8',
20
+ '.png': 'image/png',
21
+ '.jpg': 'image/jpeg',
22
+ '.jpeg': 'image/jpeg',
23
+ '.gif': 'image/gif',
24
+ '.svg': 'image/svg+xml',
25
+ '.ico': 'image/x-icon',
26
+ '.webp': 'image/webp',
27
+ '.woff': 'font/woff',
28
+ '.woff2': 'font/woff2',
29
+ '.ttf': 'font/ttf',
30
+ '.txt': 'text/plain; charset=utf-8',
31
+ '.mp4': 'video/mp4'
32
+ };
33
+
34
+ const IGNORED_DIRS = new Set([
35
+ 'node_modules',
36
+ '.git',
37
+ '.github',
38
+ 'dist',
39
+ 'build',
40
+ '.vscode',
41
+ '.idea',
42
+ '.gemini',
43
+ '.agents'
44
+ ]);
45
+
46
+ /**
47
+ * Extract <title> content from HTML text if available
48
+ */
49
+ function extractTitleFromHtml(content, fallbackName) {
50
+ const match = content.match(/<title[^>]*>([^<]+)<\/title>/i);
51
+ if (match && match[1]?.trim()) {
52
+ return match[1].trim();
53
+ }
54
+ return fallbackName
55
+ .replace(/\.html$/i, '')
56
+ .replace(/[-_]/g, ' ')
57
+ .replace(/\b\w/g, (c) => c.toUpperCase());
58
+ }
59
+
60
+ /** Default frame size for each category */
61
+ const CATEGORY_SIZES = {
62
+ mobile: [390, 844],
63
+ tablet: [768, 1024],
64
+ components: [720, 600],
65
+ desktop: [1280, 800],
66
+ responsive: [1200, 800]
67
+ };
68
+
69
+ /**
70
+ * Guess an artboard's category from words in its path, then from its width
71
+ */
72
+ export function inferCategory(file, width) {
73
+ const lower = file.toLowerCase();
74
+ if (/mobile|phone|iphone/.test(lower)) return 'mobile';
75
+ if (/tablet|ipad/.test(lower)) return 'tablet';
76
+ if (/component|\bui\b|atomic/.test(lower)) return 'components';
77
+ if (/desktop|laptop|screen/.test(lower)) return 'desktop';
78
+ if (width) {
79
+ if (width <= 480) return 'mobile';
80
+ if (width <= 1024) return 'tablet';
81
+ return 'desktop';
82
+ }
83
+ return 'responsive';
84
+ }
85
+
86
+ function slugify(text) {
87
+ return String(text).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
88
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
89
+ }
90
+
91
+ /** True when target is base itself or inside it */
92
+ function isInside(base, target) {
93
+ const rel = path.relative(base, target);
94
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
95
+ }
96
+
97
+ /**
98
+ * Scan target directory recursively for HTML files to auto-discover
99
+ */
100
+ export function discoverDesigns(baseDir) {
101
+ const artboards = [];
102
+
103
+ function scan(currentDir, relativePath = '') {
104
+ let entries;
105
+ try {
106
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
107
+ } catch {
108
+ return;
109
+ }
110
+
111
+ for (const entry of entries) {
112
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
113
+ if (IGNORED_DIRS.has(entry.name)) continue;
114
+
115
+ const fullPath = path.join(currentDir, entry.name);
116
+ const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
117
+
118
+ if (entry.isDirectory()) {
119
+ scan(fullPath, relPath);
120
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
121
+ let title = '';
122
+ let fileContent = '';
123
+ try {
124
+ fileContent = fs.readFileSync(fullPath, 'utf8');
125
+ title = extractTitleFromHtml(fileContent, entry.name);
126
+ } catch {
127
+ title = entry.name;
128
+ }
129
+
130
+ const category = inferCategory(relPath);
131
+ const [width, height] = CATEGORY_SIZES[category];
132
+
133
+ const sectionName = relativePath ? relativePath.replace(/[\\/]/g, ' / ') : 'Root Designs';
134
+
135
+ artboards.push({
136
+ id: slugify(relPath.replace(/\.html$/i, '')),
137
+ title,
138
+ file: relPath.replace(/\\/g, '/'),
139
+ category,
140
+ width,
141
+ height,
142
+ sectionName,
143
+ tags: [category]
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ scan(baseDir);
150
+
151
+ // Group into sections
152
+ const sectionsMap = new Map();
153
+ for (const board of artboards) {
154
+ const sName = board.sectionName;
155
+ if (!sectionsMap.has(sName)) {
156
+ sectionsMap.set(sName, {
157
+ id: sName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
158
+ name: sName,
159
+ description: `HTML artboards discovered in ${sName}`,
160
+ artboards: []
161
+ });
162
+ }
163
+ const { sectionName, ...rest } = board;
164
+ sectionsMap.get(sName).artboards.push(rest);
165
+ }
166
+
167
+ const projectName = path.basename(path.resolve(baseDir)) || 'Design Explorer';
168
+
169
+ return {
170
+ title: projectName,
171
+ description: 'Auto-discovered HTML artboards in workspace',
172
+ autoDiscovered: true,
173
+ sections: Array.from(sectionsMap.values())
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Fill in what a manifest may leave out, so the explorer always gets
179
+ * `sections[].{id, name, artboards[].{id, title, file, category}}`.
180
+ * Accepts the short form too: `sections[].title` and `sections[].designs[]`
181
+ * with only `{ file, title, width, height }`.
182
+ * `prefix` is prepended to every file (the designs folder relative to the served root).
183
+ */
184
+ export function normalizeManifest(manifest, prefix = '') {
185
+ const usedIds = new Set();
186
+ const uniqueId = (wanted) => {
187
+ let id = wanted || 'artboard';
188
+ for (let n = 2; usedIds.has(id); n++) id = `${wanted}-${n}`;
189
+ usedIds.add(id);
190
+ return id;
191
+ };
192
+
193
+ const sections = (Array.isArray(manifest.sections) ? manifest.sections : []).map((section, i) => {
194
+ const name = section.name ?? section.title ?? `Section ${i + 1}`;
195
+ const boards = section.artboards ?? section.designs ?? [];
196
+ return {
197
+ ...section,
198
+ id: section.id ?? (slugify(name) || `section-${i + 1}`),
199
+ name,
200
+ artboards: boards.filter((board) => board && board.file).map((board) => {
201
+ const file = prefix ? path.posix.join(prefix, board.file) : board.file;
202
+ return {
203
+ ...board,
204
+ id: uniqueId(board.id ?? slugify(board.file.replace(/\.html?(?=$|\?)/i, ''))),
205
+ title: board.title ?? board.file,
206
+ file,
207
+ category: board.category ?? inferCategory(board.file.split('?')[0], board.width)
208
+ };
209
+ })
210
+ };
211
+ });
212
+
213
+ return { ...manifest, sections };
214
+ }
215
+
216
+ /**
217
+ * Load designs configuration from designs.json or auto-discover.
218
+ * With `rootDir` (a folder containing baseDir), artboard paths are given relative to it.
219
+ */
220
+ export function loadDesignsManifest(baseDir, rootDir = baseDir) {
221
+ const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
222
+ const configPath = path.join(baseDir, 'designs.json');
223
+ if (fs.existsSync(configPath)) {
224
+ try {
225
+ const raw = fs.readFileSync(configPath, 'utf8');
226
+ const data = JSON.parse(raw);
227
+ return {
228
+ ...normalizeManifest(data, prefix),
229
+ autoDiscovered: false
230
+ };
231
+ } catch (err) {
232
+ console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Falling back to auto-discovery.`);
233
+ }
234
+ }
235
+ return normalizeManifest(discoverDesigns(baseDir), prefix);
236
+ }
237
+
238
+ /** Folders the watcher leaves alone: hidden ones (.git, .vscode, …) and dependencies */
239
+ const skipWatching = (name) => name.startsWith('.') || name === 'node_modules';
240
+
241
+ /**
242
+ * Watch a folder and everything under it, calling onChange(path, isFolder) with the path (relative,
243
+ * with forward slashes) of every file or folder created, changed or removed.
244
+ * macOS and Windows watch a whole tree natively. On Linux Node emulates that with one watcher per
245
+ * file, which loses a file for good once it is saved by replacing it (writing a copy and renaming
246
+ * it over, as many editors do), so there every folder gets its own watcher instead.
247
+ * Returns a function that stops watching.
248
+ */
249
+ export function watchTree(rootDir, onChange) {
250
+ const relative = (full) => path.relative(rootDir, full).split(path.sep).join('/');
251
+ const skipped = (rel) => rel.split('/').some(skipWatching);
252
+
253
+ if (process.platform !== 'linux') {
254
+ const watcher = fs.watch(rootDir, { recursive: true }, (eventType, filename) => {
255
+ if (!filename) return;
256
+ const rel = String(filename).split(path.sep).join('/');
257
+ if (skipped(rel)) return;
258
+ let isFolder;
259
+ try {
260
+ isFolder = fs.statSync(path.join(rootDir, rel)).isDirectory();
261
+ } catch {
262
+ isFolder = path.extname(rel) === ''; // gone: a name without an extension was likely a folder
263
+ }
264
+ onChange(rel, isFolder);
265
+ });
266
+ return () => watcher.close();
267
+ }
268
+
269
+ const watchers = new Map(); // folder → its watcher
270
+ const unwatch = (dir) => {
271
+ for (const [watched, watcher] of watchers) {
272
+ if (watched === dir || watched.startsWith(dir + path.sep)) {
273
+ watcher.close();
274
+ watchers.delete(watched);
275
+ }
276
+ }
277
+ };
278
+ const watch = (dir) => {
279
+ if (watchers.has(dir)) return;
280
+ let watcher;
281
+ try {
282
+ watcher = fs.watch(dir, (eventType, name) => {
283
+ if (!name) return;
284
+ const full = path.join(dir, String(name));
285
+ const rel = relative(full);
286
+ if (!rel || rel.startsWith('..') || skipped(rel)) return;
287
+ let isDir = false;
288
+ try {
289
+ isDir = fs.statSync(full).isDirectory();
290
+ } catch {
291
+ // Removed
292
+ }
293
+ const wasDir = watchers.has(full);
294
+ if (isDir) watch(full);
295
+ else if (wasDir) unwatch(full);
296
+ onChange(rel, isDir || wasDir);
297
+ });
298
+ } catch {
299
+ return; // gone already, or unreadable
300
+ }
301
+ watcher.on('error', () => unwatch(dir));
302
+ watchers.set(dir, watcher);
303
+ let entries = [];
304
+ try {
305
+ entries = fs.readdirSync(dir, { withFileTypes: true });
306
+ } catch {
307
+ // Unreadable: its own watcher still reports what happens in it
308
+ }
309
+ for (const entry of entries) {
310
+ if (entry.isDirectory() && !skipWatching(entry.name)) watch(path.join(dir, entry.name));
311
+ }
312
+ };
313
+
314
+ watch(rootDir);
315
+ return () => {
316
+ for (const watcher of watchers.values()) watcher.close();
317
+ watchers.clear();
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Find the next available port using net.createServer
323
+ */
324
+ export function findAvailablePort(startPort) {
325
+ return new Promise((resolve, reject) => {
326
+ const tester = net.createServer();
327
+ tester.once('error', (err) => {
328
+ if (err.code === 'EADDRINUSE') {
329
+ resolve(findAvailablePort(startPort + 1));
330
+ } else {
331
+ reject(err);
332
+ }
333
+ });
334
+ tester.once('listening', () => {
335
+ const port = tester.address().port;
336
+ tester.close(() => resolve(port));
337
+ });
338
+ tester.listen(startPort);
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Creates and starts the HtmlBoard server
344
+ */
345
+ export function createServer(options = {}) {
346
+ const targetDir = path.resolve(options.dir || process.cwd());
347
+ // Files are served from rootDir, so artboards can link to shared files above the designs folder
348
+ const rootDir = path.resolve(options.root || targetDir);
349
+ if (!isInside(rootDir, targetDir)) {
350
+ throw new Error(`The designs folder (${targetDir}) must be inside the root (${rootDir})`);
351
+ }
352
+ const manifestFile = path.posix.join(path.relative(rootDir, targetDir).split(path.sep).join('/'), 'designs.json');
353
+ const clients = new Set();
354
+
355
+ const server = http.createServer((req, res) => {
356
+ const parsedUrl = new URL(req.url, 'http://localhost');
357
+ let pathname = decodeURIComponent(parsedUrl.pathname);
358
+
359
+ // API: Designs Manifest
360
+ if (pathname === '/api/designs') {
361
+ const manifest = loadDesignsManifest(targetDir, rootDir);
362
+ res.writeHead(200, {
363
+ 'Content-Type': 'application/json',
364
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
365
+ });
366
+ res.end(JSON.stringify(manifest, null, 2));
367
+ return;
368
+ }
369
+
370
+ // API: SSE Live Reload stream
371
+ if (pathname === '/api/events') {
372
+ res.writeHead(200, {
373
+ 'Content-Type': 'text/event-stream',
374
+ 'Cache-Control': 'no-cache',
375
+ 'Connection': 'keep-alive',
376
+ 'Access-Control-Allow-Origin': '*'
377
+ });
378
+
379
+ res.write(`event: connected\ndata: ${JSON.stringify({ time: Date.now() })}\n\n`);
380
+ clients.add(res);
381
+
382
+ const heartbeat = setInterval(() => {
383
+ res.write(': ping\n\n');
384
+ }, 25000);
385
+
386
+ req.on('close', () => {
387
+ clearInterval(heartbeat);
388
+ clients.delete(res);
389
+ });
390
+ return;
391
+ }
392
+
393
+ // API: Health status
394
+ if (pathname === '/api/health') {
395
+ res.writeHead(200, { 'Content-Type': 'application/json' });
396
+ res.end(JSON.stringify({ status: 'ok', version: VERSION, dir: targetDir, root: rootDir }));
397
+ return;
398
+ }
399
+
400
+ // Serve HtmlBoard Explorer client files
401
+ if (pathname === '/' || pathname === '/index.html') {
402
+ serveStaticFile(path.join(CLIENT_DIR, 'index.html'), res);
403
+ return;
404
+ }
405
+
406
+ if (pathname.startsWith('/__explorer/')) {
407
+ const clientRelPath = pathname.replace('/__explorer/', '');
408
+ const clientFilePath = path.join(CLIENT_DIR, clientRelPath);
409
+ // Ensure path is within CLIENT_DIR
410
+ if (!clientFilePath.startsWith(CLIENT_DIR)) {
411
+ res.writeHead(403).end('Forbidden');
412
+ return;
413
+ }
414
+ serveStaticFile(clientFilePath, res);
415
+ return;
416
+ }
417
+
418
+ // Serve user artboards and assets from rootDir
419
+ let safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
420
+ if (safePath.startsWith('/')) {
421
+ safePath = safePath.slice(1);
422
+ }
423
+
424
+ const filePath = path.join(rootDir, safePath);
425
+
426
+ // Security check: must stay within rootDir
427
+ if (!isInside(rootDir, filePath)) {
428
+ res.writeHead(403).end('Forbidden');
429
+ return;
430
+ }
431
+
432
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
433
+ serveStaticFile(filePath, res);
434
+ } else {
435
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
436
+ res.end(`File not found: ${pathname}`);
437
+ }
438
+ });
439
+
440
+ function serveStaticFile(filePath, res) {
441
+ try {
442
+ const ext = path.extname(filePath).toLowerCase();
443
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
444
+ const content = fs.readFileSync(filePath);
445
+ res.writeHead(200, {
446
+ 'Content-Type': contentType,
447
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
448
+ });
449
+ res.end(content);
450
+ } catch {
451
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
452
+ res.end('Error reading file');
453
+ }
454
+ }
455
+
456
+ // Live reload. Saves come in bursts (an editor writing a file several times, an agent saving a
457
+ // page and its stylesheet together), so changes are collected for 100 ms and then sent once each.
458
+ const changed = new Map(); // path → is a folder
459
+ let watchDebounceTimer = null;
460
+ let stopWatching = null;
461
+
462
+ try {
463
+ stopWatching = watchTree(rootDir, (file, isFolder) => {
464
+ if (file.endsWith('~') || file.endsWith('.tmp')) return; // editor scratch files
465
+ changed.set(file, isFolder);
466
+ clearTimeout(watchDebounceTimer);
467
+ watchDebounceTimer = setTimeout(() => {
468
+ const files = [...changed];
469
+ changed.clear();
470
+ for (const [file, isFolder] of files) {
471
+ const eventData = JSON.stringify({
472
+ type: file === manifestFile ? 'manifest' : isFolder ? 'folder' : 'file',
473
+ file,
474
+ isHtml: !isFolder && /\.html?$/i.test(file),
475
+ timestamp: Date.now()
476
+ });
477
+ for (const client of clients) {
478
+ client.write(`event: change\ndata: ${eventData}\n\n`);
479
+ }
480
+ }
481
+ }, 100);
482
+ });
483
+ } catch (err) {
484
+ console.warn(`[htmlboard] Live watcher error: ${err.message}`);
485
+ }
486
+
487
+ return {
488
+ server,
489
+ targetDir,
490
+ rootDir,
491
+ close: () => {
492
+ clearTimeout(watchDebounceTimer);
493
+ if (stopWatching) stopWatching();
494
+ for (const client of clients) {
495
+ client.end();
496
+ }
497
+ return new Promise((resolve) => server.close(resolve));
498
+ }
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Start listening on port with auto-retry if in use
504
+ */
505
+ export async function startServer(options = {}) {
506
+ const requestedPort = parseInt(options.port, 10) || 3000;
507
+ const actualPort = await findAvailablePort(requestedPort);
508
+
509
+ if (actualPort !== requestedPort) {
510
+ console.log(`[htmlboard] Port ${requestedPort} is in use, using ${actualPort}...`);
511
+ }
512
+
513
+ const { server, targetDir, rootDir, close } = createServer(options);
514
+
515
+ return new Promise((resolve, reject) => {
516
+ server.once('error', reject);
517
+ server.listen(actualPort, () => {
518
+ const url = `http://localhost:${actualPort}`;
519
+ resolve({
520
+ server,
521
+ port: actualPort,
522
+ url,
523
+ targetDir,
524
+ rootDir,
525
+ close
526
+ });
527
+ });
528
+ });
529
+ }
package/src/skills.js ADDED
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ /** Agent skills bundled with the package, one folder per skill */
10
+ export const SKILLS_DIR = path.join(__dirname, '..', 'skills');
11
+
12
+ /**
13
+ * Where each agent looks for skills, relative to the project root (or the home folder with `global`).
14
+ * `agents` is the shared Agent Skills location read by Codex and other agents.
15
+ */
16
+ export const SKILL_TARGETS = {
17
+ claude: path.join('.claude', 'skills'),
18
+ agents: path.join('.agents', 'skills')
19
+ };
20
+
21
+ function bundledSkills() {
22
+ return fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
23
+ .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(SKILLS_DIR, entry.name, 'SKILL.md')))
24
+ .map((entry) => entry.name);
25
+ }
26
+
27
+ function resolveTargets(agent = 'all') {
28
+ if (agent === 'all') return Object.keys(SKILL_TARGETS);
29
+ if (!SKILL_TARGETS[agent]) {
30
+ throw new Error(`Unknown agent "${agent}". Use one of: ${[...Object.keys(SKILL_TARGETS), 'all'].join(', ')}`);
31
+ }
32
+ return [agent];
33
+ }
34
+
35
+ /** List every file under dir as paths relative to it */
36
+ function listFiles(dir, prefix = '') {
37
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
38
+ const rel = path.join(prefix, entry.name);
39
+ return entry.isDirectory() ? listFiles(path.join(dir, entry.name), rel) : [rel];
40
+ });
41
+ }
42
+
43
+ function sameTree(a, b) {
44
+ const filesA = listFiles(a).sort();
45
+ const filesB = listFiles(b).sort();
46
+ return filesA.length === filesB.length &&
47
+ filesA.every((file, i) => file === filesB[i] &&
48
+ fs.readFileSync(path.join(a, file)).equals(fs.readFileSync(path.join(b, file))));
49
+ }
50
+
51
+ /**
52
+ * Copy the bundled skills into the agents' skill folders.
53
+ * An existing skill folder that differs from the bundled one is left alone unless `force` is set.
54
+ *
55
+ * @param {object} [options]
56
+ * @param {'claude'|'agents'|'all'} [options.agent='all']
57
+ * @param {boolean} [options.global=false] Install under the home folder instead of the project
58
+ * @param {string} [options.dir=process.cwd()] Project root (ignored with `global`)
59
+ * @param {boolean} [options.force=false] Replace skills that were changed locally
60
+ * @returns {{skill: string, path: string, status: 'installed'|'updated'|'unchanged'|'skipped'}[]}
61
+ */
62
+ export function installSkills({ agent = 'all', global = false, dir = process.cwd(), force = false } = {}) {
63
+ const base = global ? os.homedir() : path.resolve(dir);
64
+ const results = [];
65
+
66
+ for (const target of resolveTargets(agent)) {
67
+ for (const skill of bundledSkills()) {
68
+ const src = path.join(SKILLS_DIR, skill);
69
+ const dest = path.join(base, SKILL_TARGETS[target], skill);
70
+ let status = 'installed';
71
+
72
+ if (fs.existsSync(dest)) {
73
+ if (sameTree(src, dest)) {
74
+ results.push({ skill, path: dest, status: 'unchanged' });
75
+ continue;
76
+ }
77
+ if (!force) {
78
+ results.push({ skill, path: dest, status: 'skipped' });
79
+ continue;
80
+ }
81
+ fs.rmSync(dest, { recursive: true, force: true });
82
+ status = 'updated';
83
+ }
84
+
85
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
86
+ fs.cpSync(src, dest, { recursive: true });
87
+ results.push({ skill, path: dest, status });
88
+ }
89
+ }
90
+
91
+ return results;
92
+ }
93
+
94
+ /**
95
+ * Remove the bundled skills from the agents' skill folders.
96
+ * Takes the same options as installSkills (except `force`).
97
+ * @returns {{skill: string, path: string, status: 'removed'|'absent'}[]}
98
+ */
99
+ export function uninstallSkills({ agent = 'all', global = false, dir = process.cwd() } = {}) {
100
+ const base = global ? os.homedir() : path.resolve(dir);
101
+ const results = [];
102
+
103
+ for (const target of resolveTargets(agent)) {
104
+ for (const skill of bundledSkills()) {
105
+ const dest = path.join(base, SKILL_TARGETS[target], skill);
106
+ if (fs.existsSync(dest)) {
107
+ fs.rmSync(dest, { recursive: true, force: true });
108
+ results.push({ skill, path: dest, status: 'removed' });
109
+ } else {
110
+ results.push({ skill, path: dest, status: 'absent' });
111
+ }
112
+ }
113
+ }
114
+
115
+ return results;
116
+ }