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.
- package/.env.example +11 -0
- package/README.md +75 -1
- package/docs/index.html +154 -16
- package/docs/mcp.md +78 -0
- package/docs/ssh.md +82 -0
- package/docs/vibedit-analysis.md +375 -0
- package/docs/vim.md +110 -0
- package/docs/voice.md +4 -0
- package/package.json +9 -5
- package/scripts/test-vibedit.js +45 -0
- package/scripts/vibedit-demo.sh +52 -0
- package/skills/shmakk-skill-creator.md +269 -0
- package/src/_check.js +7 -0
- package/src/_check_schema.js +5 -0
- package/src/_cleanup.js +18 -0
- package/src/_fix.js +9 -0
- package/src/_test_import.js +15 -0
- package/src/agent.js +11 -4
- package/src/browser-daemon.js +209 -0
- package/src/browser.js +10 -0
- package/src/cli/browserDaemon.js +60 -0
- package/src/cli/connectBrowser.js +137 -0
- package/src/cli.js +235 -8
- package/src/completions.js +8 -0
- package/src/control.js +273 -1
- package/src/core/browserConnector.js +523 -0
- package/src/electron.js +305 -0
- package/src/endpoints.js +74 -9
- package/src/index.js +24 -1
- package/src/llm.js +501 -61
- package/src/mobile.js +307 -0
- package/src/notify.js +51 -3
- package/src/orchestrator.js +35 -1
- package/src/pty.js +11 -6
- package/src/review.js +45 -11
- package/src/self-commands.js +153 -0
- package/src/session-convert.js +508 -0
- package/src/session-search.js +31 -0
- package/src/session.js +384 -46
- package/src/skills/browserActions.ts +984 -0
- package/src/skills.js +451 -24
- package/src/system-prompt.js +31 -25
- package/src/tools.js +81 -0
- package/src/vibedit/control.js +534 -0
- package/src/vibedit/electron.js +108 -0
- package/src/vibedit/files.js +171 -0
- package/src/vibedit/index.js +298 -0
- package/src/vibedit/overlay.js +1482 -0
- package/src/vibedit/prompts.js +245 -0
- package/src/vibedit/state.js +32 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
297
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
995
|
+
// Check for SKILL.md inside → directory skill
|
|
595
996
|
const subDir = path.join(dir, entry.name);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1112
|
+
bytes: activeBytes,
|
|
686
1113
|
} : null,
|
|
687
1114
|
total: all.length,
|
|
688
1115
|
};
|