shmakk 1.2.4 → 1.2.5

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.
Files changed (51) hide show
  1. package/.env.example +11 -0
  2. package/README.md +75 -1
  3. package/docs/index.html +154 -16
  4. package/docs/mcp.md +78 -0
  5. package/docs/ssh.md +82 -0
  6. package/docs/vibedit-analysis.md +375 -0
  7. package/docs/vim.md +110 -0
  8. package/docs/voice.md +4 -0
  9. package/package.json +9 -5
  10. package/scripts/test-vibedit.js +45 -0
  11. package/scripts/vibedit-demo.sh +52 -0
  12. package/skills/shmakk-skill-creator.md +269 -0
  13. package/src/_check.js +7 -0
  14. package/src/_check_schema.js +5 -0
  15. package/src/_cleanup.js +18 -0
  16. package/src/_fix.js +9 -0
  17. package/src/_test_import.js +15 -0
  18. package/src/agent.js +11 -4
  19. package/src/browser-daemon.js +209 -0
  20. package/src/browser.js +10 -0
  21. package/src/cli/browserDaemon.js +60 -0
  22. package/src/cli/connectBrowser.js +137 -0
  23. package/src/cli.js +235 -8
  24. package/src/completions.js +8 -0
  25. package/src/control.js +273 -1
  26. package/src/core/browserConnector.js +523 -0
  27. package/src/electron.js +305 -0
  28. package/src/endpoints.js +74 -9
  29. package/src/index.js +24 -1
  30. package/src/llm.js +501 -61
  31. package/src/mobile.js +307 -0
  32. package/src/notify.js +51 -3
  33. package/src/orchestrator.js +35 -1
  34. package/src/pty.js +11 -6
  35. package/src/review.js +45 -11
  36. package/src/self-commands.js +153 -0
  37. package/src/session-convert.js +508 -0
  38. package/src/session-search.js +31 -0
  39. package/src/session.js +384 -46
  40. package/src/skills/browserActions.ts +984 -0
  41. package/src/skills.js +451 -24
  42. package/src/system-prompt.js +31 -25
  43. package/src/tools.js +81 -0
  44. package/src/vibedit/control.js +534 -0
  45. package/src/vibedit/electron.js +108 -0
  46. package/src/vibedit/files.js +171 -0
  47. package/src/vibedit/index.js +298 -0
  48. package/src/vibedit/overlay.js +1482 -0
  49. package/src/vibedit/prompts.js +245 -0
  50. package/src/vibedit/state.js +32 -0
  51. package/src/vim.js +410 -0
package/src/skills.js CHANGED
@@ -50,13 +50,14 @@ function candidatePaths(name, cwd = process.cwd()) {
50
50
 
51
51
  // The global skills directory is now organized into category subdirectories.
52
52
  // Scan all subdirs at startup so `load skill <name>` finds it regardless of
53
- // which category folder it lives in.
53
+ // which category folder it lives in. Also check for directory skills.
54
54
  const globalSubdirHits = [];
55
55
  try {
56
56
  if (fs.existsSync(globalRoot)) {
57
57
  for (const entry of fs.readdirSync(globalRoot, { withFileTypes: true })) {
58
58
  if (entry.isDirectory()) {
59
59
  globalSubdirHits.push(path.join(globalRoot, entry.name, `${n}.md`));
60
+ globalSubdirHits.push(path.join(globalRoot, entry.name, n, 'SKILL.md'));
60
61
  }
61
62
  }
62
63
  }
@@ -79,6 +80,7 @@ function candidatePaths(name, cwd = process.cwd()) {
79
80
  path.join(home, '.codex', 'skills', n, 'SKILL.md'),
80
81
  // Global config — flat layout + category subdirectories
81
82
  path.join(globalRoot, `${n}.md`),
83
+ path.join(globalRoot, n, 'SKILL.md'),
82
84
  ...globalSubdirHits,
83
85
  // Package-bundled fallback (last resort)
84
86
  path.join(__dirname, '..', 'skills', `${n}.md`),
@@ -132,6 +134,80 @@ function sha256(s) {
132
134
  return require('crypto').createHash('sha256').update(String(s || ''), 'utf8').digest('hex');
133
135
  }
134
136
 
137
+ // Detect whether a candidate path is a directory skill (SKILL.md inside a named directory).
138
+ // Returns { dir, name } or null.
139
+ function detectDirectorySkill(candidatePath) {
140
+ if (path.basename(candidatePath) !== 'SKILL.md') return null;
141
+ const dir = path.dirname(candidatePath);
142
+ const name = path.basename(dir);
143
+ if (!name || name === '.' || name === '..') return null;
144
+ return { dir, name: safeName(name) };
145
+ }
146
+
147
+ // Walk a skill directory, returning an array of { relPath, absPath, size } for all files.
148
+ // Skips common ignored patterns: node_modules, .git, __pycache__, .DS_Store, *.tmp.
149
+ function walkSkillDir(dirPath) {
150
+ const files = [];
151
+ const ignored = new Set(['node_modules', '.git', '__pycache__', '.DS_Store']);
152
+ function walk(current, relBase) {
153
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
154
+ if (entry.name.startsWith('.') && entry.name !== '.gitkeep') continue;
155
+ if (ignored.has(entry.name)) continue;
156
+ const abs = path.join(current, entry.name);
157
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
158
+ if (entry.isFile()) {
159
+ try {
160
+ files.push({ relPath: rel, absPath: abs, size: fs.statSync(abs).size });
161
+ } catch {}
162
+ } else if (entry.isDirectory()) {
163
+ walk(abs, rel);
164
+ }
165
+ }
166
+ }
167
+ try { walk(dirPath, ''); } catch {}
168
+ return files.sort((a, b) => String(a.relPath).localeCompare(String(b.relPath)));
169
+ }
170
+
171
+ // Copy a directory tree recursively. Overwrites destination.
172
+ function copyDirRecursive(src, dest) {
173
+ fs.mkdirSync(dest, { recursive: true });
174
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
175
+ const srcPath = path.join(src, entry.name);
176
+ const destPath = path.join(dest, entry.name);
177
+ if (entry.isDirectory()) {
178
+ copyDirRecursive(srcPath, destPath);
179
+ } else {
180
+ fs.copyFileSync(srcPath, destPath);
181
+ }
182
+ }
183
+ }
184
+
185
+ // Get the maximum mtime across all files in a directory (for cache invalidation).
186
+ function collectDirMtime(dirPath) {
187
+ let maxMtime = 0;
188
+ function walk(current) {
189
+ try {
190
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
191
+ const abs = path.join(current, entry.name);
192
+ if (entry.isFile()) {
193
+ try { maxMtime = Math.max(maxMtime, fs.statSync(abs).mtimeMs); } catch {}
194
+ } else if (entry.isDirectory() && !entry.name.startsWith('.')) {
195
+ walk(abs);
196
+ }
197
+ }
198
+ } catch {}
199
+ }
200
+ walk(dirPath);
201
+ return maxMtime || 0;
202
+ }
203
+
204
+ // Read the SKILL.md content from a directory skill.
205
+ function readDirSkillMd(dirPath) {
206
+ const mdPath = path.join(dirPath, 'SKILL.md');
207
+ if (!fs.existsSync(mdPath)) return null;
208
+ return fs.readFileSync(mdPath, 'utf8');
209
+ }
210
+
135
211
  function parseFrontmatter(raw) {
136
212
  const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/m.exec(String(raw || ''));
137
213
  if (!m) return { meta: {}, body: String(raw || '') };
@@ -201,6 +277,53 @@ function loadSkillToWorkspace(name, cwd = process.cwd()) {
201
277
  };
202
278
  }
203
279
 
280
+ const dirSkill = detectDirectorySkill(found);
281
+
282
+ if (dirSkill) {
283
+ // ── Directory skill (multiple files) ──
284
+ const raw = readDirSkillMd(dirSkill.dir);
285
+ if (!raw) return { ok: false, error: `directory skill missing SKILL.md: ${dirSkill.dir}` };
286
+ const validation = validateSkill(raw, found);
287
+ if (!validation.ok) {
288
+ return { ok: false, error: `skill failed validation: ${validation.issues.join('; ')}` };
289
+ }
290
+
291
+ ensureDirs(cwd);
292
+ const destDir = path.join(skillsDir(cwd), validation.normalizedName);
293
+ // Only remove/copy if source and destination differ
294
+ if (path.resolve(dirSkill.dir) !== path.resolve(destDir)) {
295
+ try { fs.rmSync(destDir, { recursive: true, force: true }); } catch {}
296
+ copyDirRecursive(dirSkill.dir, destDir);
297
+ }
298
+
299
+ const manifest = walkSkillDir(destDir);
300
+ const totalBytes = manifest.reduce((sum, f) => sum + f.size, 0);
301
+ const checksum = sha256(manifest.map((f) => `${f.relPath}:${f.size}`).join('\n'));
302
+
303
+ const registry = loadRegistry(cwd);
304
+ registry.skills[validation.normalizedName] = {
305
+ name: validation.normalizedName,
306
+ version: validation.version,
307
+ source: dirSkill.dir,
308
+ localPath: destDir,
309
+ type: 'directory',
310
+ checksum,
311
+ bytes: totalBytes,
312
+ files: manifest.map((f) => ({ rel: f.relPath, size: f.size })),
313
+ loadedAt: new Date().toISOString(),
314
+ active: true,
315
+ };
316
+
317
+ for (const k of Object.keys(registry.skills)) {
318
+ if (k !== validation.normalizedName) registry.skills[k].active = false;
319
+ }
320
+ saveRegistry(cwd, registry);
321
+ fs.writeFileSync(activeSkillPath(cwd), JSON.stringify(registry.skills[validation.normalizedName], null, 2));
322
+
323
+ return { ok: true, name: validation.normalizedName, source: dirSkill.dir, localPath: destDir, version: validation.version, type: 'directory' };
324
+ }
325
+
326
+ // ── Single-file skill ──
204
327
  const raw = fs.readFileSync(found, 'utf8');
205
328
  const validation = validateSkill(raw, found);
206
329
  if (!validation.ok) {
@@ -218,6 +341,7 @@ function loadSkillToWorkspace(name, cwd = process.cwd()) {
218
341
  version: validation.version,
219
342
  source: found,
220
343
  localPath: localSkillPath,
344
+ type: 'file',
221
345
  checksum,
222
346
  bytes: Buffer.byteLength(validation.raw, 'utf8'),
223
347
  loadedAt: new Date().toISOString(),
@@ -270,7 +394,12 @@ function readActiveSkill(cwd = process.cwd()) {
270
394
  if (!fs.existsSync(p)) return null;
271
395
  const meta = JSON.parse(fs.readFileSync(p, 'utf8'));
272
396
  if (!meta || !meta.localPath || !fs.existsSync(meta.localPath)) return null;
273
- const content = fs.readFileSync(meta.localPath, 'utf8');
397
+ let content;
398
+ if (meta.type === 'directory') {
399
+ content = readDirSkillMd(meta.localPath);
400
+ } else {
401
+ content = fs.readFileSync(meta.localPath, 'utf8');
402
+ }
274
403
  return { ...meta, content };
275
404
  } catch {
276
405
  return null;
@@ -289,12 +418,43 @@ function renderActiveSkillForPrompt(cwd = process.cwd(), maxBytes = DEFAULT_REND
289
418
  return '';
290
419
  }
291
420
  let mtime = 0;
292
- try { mtime = fs.statSync(skill.localPath).mtimeMs; } catch {}
421
+ if (skill.type === 'directory') {
422
+ try { mtime = collectDirMtime(skill.localPath); } catch {}
423
+ } else {
424
+ try { mtime = fs.statSync(skill.localPath).mtimeMs; } catch {}
425
+ }
293
426
  const cacheKey = `${cwd}|${skill.localPath}|${mtime}|${maxBytes}`;
294
427
  if (_skillPromptCache.key === cacheKey) return _skillPromptCache.value;
295
428
 
296
- const body = String(skill.content || '').slice(0, Math.max(1000, Number(maxBytes) || DEFAULT_RENDER_BYTES));
297
- const prompt = `Active loaded skill (${skill.name}${skill.version ? ` v${skill.version}` : ''}) instructions:\n${body}`;
429
+ const maxB = Math.max(1000, Number(maxBytes) || DEFAULT_RENDER_BYTES);
430
+
431
+ let prompt;
432
+ if (skill.type === 'directory' && Array.isArray(skill.files) && skill.files.length > 0) {
433
+ // Build a manifest section then the SKILL.md content
434
+ const nonMdFiles = skill.files.filter((f) => f.rel !== 'SKILL.md');
435
+ let manifest = '';
436
+ if (nonMdFiles.length > 0) {
437
+ // Budget ~20% of maxBytes for manifest, 80% for SKILL.md content
438
+ const manifestBudget = Math.max(200, Math.floor(maxB * 0.2));
439
+ // Build list entries — no snippets by default to save tokens
440
+ manifest = 'Compact relevant subgraph for this skill:\n';
441
+ for (const f of nonMdFiles) {
442
+ const entry = `- ${f.rel} [role=file] size=${f.size}\n`;
443
+ if (Buffer.byteLength(manifest + entry, 'utf8') > manifestBudget) {
444
+ manifest += `- ...${nonMdFiles.length - nonMdFiles.indexOf(f)} more files not shown\n`;
445
+ break;
446
+ }
447
+ manifest += entry;
448
+ }
449
+ }
450
+ const bodyBudget = Math.max(600, maxB - Buffer.byteLength(manifest, 'utf8'));
451
+ const body = String(skill.content || '').slice(0, bodyBudget);
452
+ prompt = `Active loaded skill (${skill.name}${skill.version ? ` v${skill.version}` : ''}) instructions:\n\n${manifest}\n---\n${body}`;
453
+ } else {
454
+ const body = String(skill.content || '').slice(0, maxB);
455
+ prompt = `Active loaded skill (${skill.name}${skill.version ? ` v${skill.version}` : ''}) instructions:\n${body}`;
456
+ }
457
+
298
458
  _skillPromptCache.key = cacheKey;
299
459
  _skillPromptCache.value = prompt;
300
460
  return prompt;
@@ -328,7 +488,7 @@ function unloadSkill(name, cwd = process.cwd()) {
328
488
  if (!entry) return { ok: false, error: `skill not found in registry: ${n}` };
329
489
  delete registry.skills[n];
330
490
  if (entry.localPath) {
331
- try { fs.rmSync(entry.localPath, { force: true }); } catch {}
491
+ try { fs.rmSync(entry.localPath, { recursive: true, force: true }); } catch {}
332
492
  }
333
493
  const active = readActiveSkill(cwd);
334
494
  if (active && safeName(active.name) === n) {
@@ -341,13 +501,17 @@ function unloadSkill(name, cwd = process.cwd()) {
341
501
  function skillStatus(cwd = process.cwd()) {
342
502
  const active = readActiveSkill(cwd);
343
503
  const all = listSkills(cwd);
504
+ const activeBytes = active && active.bytes ? active.bytes
505
+ : (active && active.type === 'directory' ? (walkSkillDir(active.localPath).reduce((s, f) => s + f.size, 0))
506
+ : Buffer.byteLength(String(active?.content || ''), 'utf8'));
344
507
  return {
345
508
  active: active ? {
346
509
  name: active.name,
347
510
  version: active.version || '1',
348
511
  source: active.source,
512
+ type: active.type || 'file',
349
513
  loadedAt: active.loadedAt,
350
- bytes: active.bytes || Buffer.byteLength(String(active.content || ''), 'utf8'),
514
+ bytes: activeBytes,
351
515
  } : null,
352
516
  total: all.length,
353
517
  };
@@ -392,6 +556,31 @@ async function installSkillFromUrl(url, cwd = process.cwd()) {
392
556
  }
393
557
  }
394
558
 
559
+ // Check if this is a GitHub tree URL that points to a directory skill.
560
+ // If so, download all files and create a directory skill.
561
+ let ghDir = null;
562
+ try {
563
+ const gu = new URL(u.href);
564
+ if (/^(www\.)?github\.com$/i.test(gu.host)) {
565
+ const parts = gu.pathname.split('/').filter(Boolean);
566
+ if (parts.length >= 5 && parts[2] === 'tree') {
567
+ const owner = parts[0];
568
+ const repo = parts[1];
569
+ const ref = parts[3];
570
+ const relPath = parts.slice(4).join('/');
571
+ // Only treat as directory if the path doesn't end with .md
572
+ if (!/\.(md|markdown)$/i.test(relPath)) {
573
+ ghDir = { owner, repo, ref, relPath };
574
+ }
575
+ }
576
+ }
577
+ } catch {}
578
+
579
+ if (ghDir) {
580
+ // Download all files from GitHub directory and create a directory skill
581
+ return _installDirectorySkillFromGitHub(ghDir, cwd);
582
+ }
583
+
395
584
  const resolvedUrl = await resolveGitHubUrl(u.href);
396
585
  let finalUrl;
397
586
  try { finalUrl = new URL(resolvedUrl); } catch { finalUrl = u; }
@@ -411,6 +600,84 @@ async function installSkillFromUrl(url, cwd = process.cwd()) {
411
600
  return importSkillContent(text, finalUrl.href, cwd, derived);
412
601
  }
413
602
 
603
+ // Download all files from a GitHub directory and create a directory skill locally.
604
+ async function _installDirectorySkillFromGitHub(ghDir, cwd) {
605
+ async function fetchGitHubDir(apiUrl) {
606
+ const resp = await fetch(apiUrl, { headers: { 'user-agent': 'shmakk-skill-installer/1.0' } });
607
+ if (!resp.ok) throw new Error(`GitHub API error ${resp.status}`);
608
+ return resp.json();
609
+ }
610
+
611
+ async function downloadAll(dirPath) {
612
+ const api = `https://api.github.com/repos/${ghDir.owner}/${ghDir.repo}/contents/${dirPath}?ref=${encodeURIComponent(ghDir.ref)}`;
613
+ const entries = await fetchGitHubDir(api);
614
+ if (!Array.isArray(entries)) throw new Error('not a directory');
615
+ const files = [];
616
+ for (const entry of entries) {
617
+ if (entry.type === 'file' && entry.download_url) {
618
+ const resp = await fetch(entry.download_url, { headers: { 'user-agent': 'shmakk-skill-installer/1.0' } });
619
+ if (resp.ok) {
620
+ files.push({ path: entry.name, content: await resp.text(), size: entry.size || 0 });
621
+ }
622
+ } else if (entry.type === 'dir') {
623
+ // Skip nested subdirs for simplicity; only one level deep
624
+ }
625
+ }
626
+ return files;
627
+ }
628
+
629
+ try {
630
+ const files = await downloadAll(ghDir.relPath);
631
+
632
+ // Find SKILL.md
633
+ const skillMd = files.find((f) => f.path === 'SKILL.md');
634
+ if (!skillMd) return { ok: false, error: 'no SKILL.md found in GitHub directory' };
635
+
636
+ const validation = validateSkill(skillMd.content, `${ghDir.owner}/${ghDir.repo}/${ghDir.relPath}/SKILL.md`);
637
+ if (!validation.ok) {
638
+ return { ok: false, error: `skill failed validation: ${validation.issues.join('; ')}` };
639
+ }
640
+
641
+ ensureDirs(cwd);
642
+ const destDir = path.join(skillsDir(cwd), validation.normalizedName);
643
+ try { fs.rmSync(destDir, { recursive: true, force: true }); } catch {}
644
+ fs.mkdirSync(destDir, { recursive: true });
645
+
646
+ const manifest = [];
647
+ for (const file of files) {
648
+ const destPath = path.join(destDir, file.path);
649
+ fs.writeFileSync(destPath, file.content, 'utf8');
650
+ manifest.push({ rel: file.path, size: Buffer.byteLength(file.content, 'utf8') });
651
+ }
652
+
653
+ const totalBytes = manifest.reduce((sum, f) => sum + f.size, 0);
654
+ const checksum = sha256(manifest.map((f) => `${f.rel}:${f.size}`).join('\n'));
655
+
656
+ const registry = loadRegistry(cwd);
657
+ registry.skills[validation.normalizedName] = {
658
+ name: validation.normalizedName,
659
+ version: validation.version,
660
+ source: `https://github.com/${ghDir.owner}/${ghDir.repo}/tree/${ghDir.ref}/${ghDir.relPath}`,
661
+ localPath: destDir,
662
+ type: 'directory',
663
+ checksum,
664
+ bytes: totalBytes,
665
+ files: manifest,
666
+ loadedAt: new Date().toISOString(),
667
+ active: true,
668
+ };
669
+ for (const k of Object.keys(registry.skills)) {
670
+ if (k !== validation.normalizedName) registry.skills[k].active = false;
671
+ }
672
+ saveRegistry(cwd, registry);
673
+ fs.writeFileSync(activeSkillPath(cwd), JSON.stringify(registry.skills[validation.normalizedName], null, 2));
674
+
675
+ return { ok: true, name: validation.normalizedName, source: `https://github.com/${ghDir.owner}/${ghDir.repo}/tree/${ghDir.ref}/${ghDir.relPath}`, localPath: destDir, version: validation.version, type: 'directory' };
676
+ } catch (e) {
677
+ return { ok: false, error: `failed to download directory skill: ${e.message}` };
678
+ }
679
+ }
680
+
414
681
  // ── Global skill management (stored in ~/.config/shmakk) ──
415
682
 
416
683
  function loadGlobalRegistry() {
@@ -440,6 +707,45 @@ function loadSkillGlobally(name) {
440
707
  };
441
708
  }
442
709
 
710
+ const dirSkill = detectDirectorySkill(found);
711
+
712
+ if (dirSkill) {
713
+ const raw = readDirSkillMd(dirSkill.dir);
714
+ if (!raw) return { ok: false, error: `directory skill missing SKILL.md: ${dirSkill.dir}` };
715
+ const validation = validateSkill(raw, found);
716
+ if (!validation.ok) {
717
+ return { ok: false, error: `skill failed validation: ${validation.issues.join('; ')}` };
718
+ }
719
+
720
+ ensureGlobalDirs();
721
+ const destDir = path.join(globalSkillsDir(), validation.normalizedName);
722
+ if (path.resolve(dirSkill.dir) !== path.resolve(destDir)) {
723
+ try { fs.rmSync(destDir, { recursive: true, force: true }); } catch {}
724
+ copyDirRecursive(dirSkill.dir, destDir);
725
+ }
726
+
727
+ const manifest = walkSkillDir(destDir);
728
+ const totalBytes = manifest.reduce((sum, f) => sum + f.size, 0);
729
+ const checksum = sha256(manifest.map((f) => `${f.relPath}:${f.size}`).join('\n'));
730
+
731
+ const registry = loadGlobalRegistry();
732
+ registry.skills[validation.normalizedName] = {
733
+ name: validation.normalizedName,
734
+ version: validation.version,
735
+ source: dirSkill.dir,
736
+ localPath: destDir,
737
+ type: 'directory',
738
+ checksum,
739
+ bytes: totalBytes,
740
+ files: manifest.map((f) => ({ rel: f.relPath, size: f.size })),
741
+ registeredAt: new Date().toISOString(),
742
+ active: false,
743
+ };
744
+ saveGlobalRegistry(registry);
745
+
746
+ return { ok: true, name: validation.normalizedName, source: dirSkill.dir, localPath: destDir, version: validation.version, type: 'directory' };
747
+ }
748
+
443
749
  const raw = fs.readFileSync(found, 'utf8');
444
750
  const validation = validateSkill(raw, found);
445
751
  if (!validation.ok) {
@@ -460,6 +766,7 @@ function loadSkillGlobally(name) {
460
766
  version: validation.version,
461
767
  source: found,
462
768
  localPath: localSkillPath,
769
+ type: 'file',
463
770
  checksum,
464
771
  bytes: Buffer.byteLength(validation.raw, 'utf8'),
465
772
  registeredAt: new Date().toISOString(),
@@ -533,6 +840,25 @@ async function installSkillFromUrlGlobally(url) {
533
840
  }
534
841
  }
535
842
 
843
+ // GitHub tree URL → directory skill?
844
+ let ghDir = null;
845
+ try {
846
+ const gu = new URL(u.href);
847
+ if (/^(www\.)?github\.com$/i.test(gu.host)) {
848
+ const parts = gu.pathname.split('/').filter(Boolean);
849
+ if (parts.length >= 5 && parts[2] === 'tree') {
850
+ const relPath = parts.slice(4).join('/');
851
+ if (!/\.(md|markdown)$/i.test(relPath)) {
852
+ ghDir = { owner: parts[0], repo: parts[1], ref: parts[3], relPath };
853
+ }
854
+ }
855
+ }
856
+ } catch {}
857
+
858
+ if (ghDir) {
859
+ return _installDirectorySkillFromGitHubToGlobal(ghDir);
860
+ }
861
+
536
862
  const resolvedUrl = await resolveGitHubUrl(u.href);
537
863
  let finalUrl;
538
864
  try { finalUrl = new URL(resolvedUrl); } catch { finalUrl = u; }
@@ -552,6 +878,76 @@ async function installSkillFromUrlGlobally(url) {
552
878
  return importGlobalSkillContent(text, finalUrl.href, derived);
553
879
  }
554
880
 
881
+ // Shared GitHub directory download logic for global installs.
882
+ async function _installDirectorySkillFromGitHubToGlobal(ghDir) {
883
+ async function fetchGitHubDir(apiUrl) {
884
+ const resp = await fetch(apiUrl, { headers: { 'user-agent': 'shmakk-skill-installer/1.0' } });
885
+ if (!resp.ok) throw new Error(`GitHub API error ${resp.status}`);
886
+ return resp.json();
887
+ }
888
+
889
+ async function downloadAll(dirPath) {
890
+ const api = `https://api.github.com/repos/${ghDir.owner}/${ghDir.repo}/contents/${dirPath}?ref=${encodeURIComponent(ghDir.ref)}`;
891
+ const entries = await fetchGitHubDir(api);
892
+ if (!Array.isArray(entries)) throw new Error('not a directory');
893
+ const files = [];
894
+ for (const entry of entries) {
895
+ if (entry.type === 'file' && entry.download_url) {
896
+ const resp = await fetch(entry.download_url, { headers: { 'user-agent': 'shmakk-skill-installer/1.0' } });
897
+ if (resp.ok) {
898
+ files.push({ path: entry.name, content: await resp.text(), size: entry.size || 0 });
899
+ }
900
+ }
901
+ }
902
+ return files;
903
+ }
904
+
905
+ try {
906
+ const files = await downloadAll(ghDir.relPath);
907
+ const skillMd = files.find((f) => f.path === 'SKILL.md');
908
+ if (!skillMd) return { ok: false, error: 'no SKILL.md found in GitHub directory' };
909
+
910
+ const validation = validateSkill(skillMd.content, `${ghDir.owner}/${ghDir.repo}/${ghDir.relPath}/SKILL.md`);
911
+ if (!validation.ok) {
912
+ return { ok: false, error: `skill failed validation: ${validation.issues.join('; ')}` };
913
+ }
914
+
915
+ ensureGlobalDirs();
916
+ const destDir = path.join(globalSkillsDir(), validation.normalizedName);
917
+ try { fs.rmSync(destDir, { recursive: true, force: true }); } catch {}
918
+ fs.mkdirSync(destDir, { recursive: true });
919
+
920
+ const manifest = [];
921
+ for (const file of files) {
922
+ const destPath = path.join(destDir, file.path);
923
+ fs.writeFileSync(destPath, file.content, 'utf8');
924
+ manifest.push({ rel: file.path, size: Buffer.byteLength(file.content, 'utf8') });
925
+ }
926
+
927
+ const totalBytes = manifest.reduce((sum, f) => sum + f.size, 0);
928
+ const checksum = sha256(manifest.map((f) => `${f.rel}:${f.size}`).join('\n'));
929
+
930
+ const registry = loadGlobalRegistry();
931
+ registry.skills[validation.normalizedName] = {
932
+ name: validation.normalizedName,
933
+ version: validation.version,
934
+ source: `https://github.com/${ghDir.owner}/${ghDir.repo}/tree/${ghDir.ref}/${ghDir.relPath}`,
935
+ localPath: destDir,
936
+ type: 'directory',
937
+ checksum,
938
+ bytes: totalBytes,
939
+ files: manifest,
940
+ registeredAt: new Date().toISOString(),
941
+ active: false,
942
+ };
943
+ saveGlobalRegistry(registry);
944
+
945
+ return { ok: true, name: validation.normalizedName, source: `https://github.com/${ghDir.owner}/${ghDir.repo}/tree/${ghDir.ref}/${ghDir.relPath}`, localPath: destDir, version: validation.version, type: 'directory' };
946
+ } catch (e) {
947
+ return { ok: false, error: `failed to download directory skill: ${e.message}` };
948
+ }
949
+ }
950
+
555
951
  function unloadSkillGlobally(name) {
556
952
  const n = safeName(name);
557
953
  const registry = loadGlobalRegistry();
@@ -559,7 +955,7 @@ function unloadSkillGlobally(name) {
559
955
  if (!entry) return { ok: false, error: `skill not found in registry: ${n}` };
560
956
  delete registry.skills[n];
561
957
  if (entry.localPath) {
562
- try { fs.rmSync(entry.localPath, { force: true }); } catch {}
958
+ try { fs.rmSync(entry.localPath, { recursive: true, force: true }); } catch {}
563
959
  }
564
960
  const active = readActiveSkillGlobally();
565
961
  if (active && safeName(active.name) === n) {
@@ -575,7 +971,12 @@ function readActiveSkillGlobally() {
575
971
  if (!fs.existsSync(p)) return null;
576
972
  const meta = JSON.parse(fs.readFileSync(p, 'utf8'));
577
973
  if (!meta || !meta.localPath || !fs.existsSync(meta.localPath)) return null;
578
- const content = fs.readFileSync(meta.localPath, 'utf8');
974
+ let content;
975
+ if (meta.type === 'directory') {
976
+ content = readDirSkillMd(meta.localPath);
977
+ } else {
978
+ content = fs.readFileSync(meta.localPath, 'utf8');
979
+ }
579
980
  return { ...meta, content };
580
981
  } catch {
581
982
  return null;
@@ -589,17 +990,29 @@ function _scanSkillsDir(dir) {
589
990
  const found = [];
590
991
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
591
992
  if (entry.isFile() && entry.name.endsWith('.md')) {
592
- found.push({ skillPath: path.join(dir, entry.name), subdir: null });
993
+ found.push({ skillPath: path.join(dir, entry.name), subdir: null, dirSkill: false });
593
994
  } else if (entry.isDirectory()) {
594
- // One level deep category subdirectory
995
+ // Check for SKILL.md inside directory skill
595
996
  const subDir = path.join(dir, entry.name);
596
- try {
597
- for (const inner of fs.readdirSync(subDir)) {
598
- if (inner.endsWith('.md')) {
599
- found.push({ skillPath: path.join(subDir, inner), subdir: entry.name });
997
+ const skillMd = path.join(subDir, 'SKILL.md');
998
+ if (fs.existsSync(skillMd)) {
999
+ found.push({ skillPath: skillMd, subdir: entry.name, dirSkill: true });
1000
+ } else {
1001
+ // One level deep — category subdirectory with .md files,
1002
+ // or nested skill directories (category/skill-name/SKILL.md)
1003
+ try {
1004
+ for (const inner of fs.readdirSync(subDir, { withFileTypes: true })) {
1005
+ if (inner.isFile() && inner.name.endsWith('.md')) {
1006
+ found.push({ skillPath: path.join(subDir, inner.name), subdir: entry.name, dirSkill: false });
1007
+ } else if (inner.isDirectory()) {
1008
+ const nestedSkillMd = path.join(subDir, inner.name, 'SKILL.md');
1009
+ if (fs.existsSync(nestedSkillMd)) {
1010
+ found.push({ skillPath: nestedSkillMd, subdir: entry.name, dirSkill: true });
1011
+ }
1012
+ }
600
1013
  }
601
- }
602
- } catch {}
1014
+ } catch {}
1015
+ }
603
1016
  }
604
1017
  }
605
1018
  return found;
@@ -613,11 +1026,20 @@ function listSkillsGlobally() {
613
1026
  const registryEntries = loadGlobalRegistry().skills || {};
614
1027
  const available = {};
615
1028
 
616
- for (const { skillPath, subdir } of _scanSkillsDir(dir)) {
1029
+ for (const { skillPath, subdir, dirSkill: isDirSkill } of _scanSkillsDir(dir)) {
617
1030
  try {
618
- const raw = fs.readFileSync(skillPath, 'utf8');
1031
+ let raw, name, bytes;
1032
+ if (isDirSkill) {
1033
+ raw = readDirSkillMd(path.dirname(skillPath));
1034
+ if (!raw) continue;
1035
+ const manifest = walkSkillDir(path.dirname(skillPath));
1036
+ bytes = manifest.reduce((sum, f) => sum + f.size, 0);
1037
+ } else {
1038
+ raw = fs.readFileSync(skillPath, 'utf8');
1039
+ bytes = Buffer.byteLength(raw, 'utf8');
1040
+ }
619
1041
  const fm = parseFrontmatter(raw);
620
- const name = safeName(fm.meta.name || path.basename(skillPath, '.md'));
1042
+ name = safeName(fm.meta.name || path.basename(skillPath, '.md'));
621
1043
  // Category source priority: subdirectory > frontmatter > 'general'
622
1044
  const cat = subdir ? normalizeCategory(subdir) : normalizeCategory(fm.meta.category);
623
1045
  // First non-blank paragraph of body = short description for catalog
@@ -630,9 +1052,10 @@ function listSkillsGlobally() {
630
1052
  version: String(fm.meta.version || '1').trim(),
631
1053
  category: cat,
632
1054
  description: desc,
633
- source: skillPath,
634
- localPath: skillPath,
635
- bytes: Buffer.byteLength(raw, 'utf8'),
1055
+ source: isDirSkill ? path.dirname(skillPath) : skillPath,
1056
+ localPath: isDirSkill ? path.dirname(skillPath) : skillPath,
1057
+ type: isDirSkill ? 'directory' : 'file',
1058
+ bytes,
636
1059
  active: false, // global skills are never auto-active
637
1060
  };
638
1061
  } catch {}
@@ -676,13 +1099,17 @@ function searchSkills(query, skills) {
676
1099
  function skillStatusGlobally() {
677
1100
  const active = readActiveSkillGlobally();
678
1101
  const all = listSkillsGlobally();
1102
+ const activeBytes = active && active.bytes ? active.bytes
1103
+ : (active && active.type === 'directory' ? (walkSkillDir(active.localPath).reduce((s, f) => s + f.size, 0))
1104
+ : Buffer.byteLength(String(active?.content || ''), 'utf8'));
679
1105
  return {
680
1106
  active: active ? {
681
1107
  name: active.name,
682
1108
  version: active.version || '1',
683
1109
  source: active.source,
1110
+ type: active.type || 'file',
684
1111
  loadedAt: active.loadedAt,
685
- bytes: active.bytes || Buffer.byteLength(String(active.content || ''), 'utf8'),
1112
+ bytes: activeBytes,
686
1113
  } : null,
687
1114
  total: all.length,
688
1115
  };