skillshark 0.1.0 → 0.3.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/discover.js CHANGED
@@ -5,6 +5,7 @@ import { existsSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { sha256hex } from './fingerprint.js';
7
7
  import { CliError } from './errors.js';
8
+ import { AGENTS, AGENT_IDS, classifyByConvention, artifactBaseName, parseGeminiToml } from './agents.js';
8
9
 
9
10
  // Never packaged, not even with --force.
10
11
  const HARD_EXCLUDE_DIRS = new Set(['.git', 'node_modules']);
@@ -30,14 +31,24 @@ function secretMatch(name) {
30
31
 
31
32
  // --- share-argument resolution (§4.1) ---------------------------------------
32
33
 
33
- // Candidate locations for a bare name, in search order.
34
+ // Candidate locations for a bare name, in search order: claude-code first
35
+ // (project before global), then every other adapter in registry order.
34
36
  export function nameCandidates(name, { cwd, home }) {
35
- return [
36
- { root: path.join(cwd, '.claude', 'skills', name), isDir: true, type: 'skill', where: '.claude/skills (project)' },
37
- { root: path.join(cwd, '.claude', 'commands', `${name}.md`), isDir: false, type: 'command', where: '.claude/commands (project)' },
38
- { root: path.join(home, '.claude', 'skills', name), isDir: true, type: 'skill', where: '~/.claude/skills (global)' },
39
- { root: path.join(home, '.claude', 'commands', `${name}.md`), isDir: false, type: 'command', where: '~/.claude/commands (global)' },
40
- ];
37
+ const out = [];
38
+ for (const id of AGENT_IDS) {
39
+ for (const loc of AGENTS[id].locations) {
40
+ const base = loc.scope === 'project' ? cwd : home;
41
+ const rel = loc.rel(name);
42
+ out.push({
43
+ root: path.join(base, ...rel),
44
+ isDir: loc.container === 'dir',
45
+ type: loc.kind,
46
+ agent: id,
47
+ where: `${loc.scope === 'project' ? '' : '~/'}${rel.slice(0, -1).join('/')} (${loc.scope}, ${AGENTS[id].label})`,
48
+ });
49
+ }
50
+ }
51
+ return out;
41
52
  }
42
53
 
43
54
  async function existsAs(p, wantDir) {
@@ -49,38 +60,74 @@ async function existsAs(p, wantDir) {
49
60
  }
50
61
  }
51
62
 
52
- // Classify an explicit path by its on-disk convention (§4.1): a directory under
53
- // .claude/skills skill, a .md under .claude/commands → command, otherwise
54
- // prompt (file) or bundle (directory).
63
+ // Classify an explicit path by its on-disk convention (§4.1): any adapter
64
+ // convention wins; otherwise prompt (file) or bundle (directory).
55
65
  export function classifyPath(absPath, isDir) {
56
- const norm = absPath.split(path.sep).join('/');
57
- if (isDir && /\/\.claude\/skills\/[^/]+$/.test(norm)) return { type: 'skill', agent: 'claude-code' };
58
- if (!isDir && /\/\.claude\/commands\/[^/]+\.md$/.test(norm)) return { type: 'command', agent: 'claude-code' };
66
+ const hit = classifyByConvention(absPath, isDir);
67
+ if (hit) return hit;
59
68
  return { type: isDir ? 'bundle' : 'prompt', agent: '' };
60
69
  }
61
70
 
62
- // List every skill/command name visible from here (for suggestions).
63
- export async function knownNames({ cwd, home }) {
64
- const names = new Set();
65
- for (const dir of [
66
- path.join(cwd, '.claude', 'skills'),
67
- path.join(home, '.claude', 'skills'),
68
- ]) {
69
- try {
70
- for (const ent of await readdir(dir, { withFileTypes: true })) {
71
- if (ent.isDirectory()) names.add(ent.name);
71
+ // Enumerate every artifact visible from here, across all adapters — powers
72
+ // the interactive picker and name suggestions.
73
+ export async function discoverAll({ cwd, home }) {
74
+ const out = [];
75
+ const seen = new Set();
76
+ for (const id of AGENT_IDS) {
77
+ for (const loc of AGENTS[id].locations) {
78
+ const base = loc.scope === 'project' ? cwd : home;
79
+ const probe = loc.rel('@');
80
+ const dir = path.join(base, ...probe.slice(0, -1));
81
+ const suffix = probe[probe.length - 1].replace('@', '');
82
+ let dirents;
83
+ try {
84
+ dirents = await readdir(dir, { withFileTypes: true });
85
+ } catch {
86
+ continue;
72
87
  }
73
- } catch { /* location absent */ }
74
- }
75
- for (const dir of [
76
- path.join(cwd, '.claude', 'commands'),
77
- path.join(home, '.claude', 'commands'),
78
- ]) {
79
- try {
80
- for (const ent of await readdir(dir, { withFileTypes: true })) {
81
- if (ent.isFile() && ent.name.endsWith('.md')) names.add(ent.name.slice(0, -3));
88
+ for (const ent of dirents) {
89
+ let name = null;
90
+ if (loc.container === 'dir' && ent.isDirectory()) name = ent.name;
91
+ else if (loc.container === 'file' && ent.isFile() && suffix && ent.name.endsWith(suffix)) {
92
+ name = artifactBaseName(ent.name);
93
+ }
94
+ if (!name || name.startsWith('.')) continue;
95
+ const root = path.join(dir, ent.name);
96
+ if (seen.has(root)) continue;
97
+ seen.add(root);
98
+ out.push({
99
+ name,
100
+ root,
101
+ isDir: loc.container === 'dir',
102
+ type: loc.kind,
103
+ agent: id,
104
+ scope: loc.scope,
105
+ });
82
106
  }
83
- } catch { /* location absent */ }
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // List every artifact name visible from here, across all adapters (suggestions).
113
+ export async function knownNames({ cwd, home }) {
114
+ const names = new Set();
115
+ for (const id of AGENT_IDS) {
116
+ for (const loc of AGENTS[id].locations) {
117
+ const base = loc.scope === 'project' ? cwd : home;
118
+ // rel('') gives us the parent dir + the filename pattern's extension
119
+ const probe = loc.rel('@');
120
+ const dir = path.join(base, ...probe.slice(0, -1));
121
+ const suffix = probe[probe.length - 1].replace('@', '');
122
+ try {
123
+ for (const ent of await readdir(dir, { withFileTypes: true })) {
124
+ if (loc.container === 'dir' && ent.isDirectory()) names.add(ent.name);
125
+ else if (loc.container === 'file' && ent.isFile() && suffix && ent.name.endsWith(suffix)) {
126
+ names.add(artifactBaseName(ent.name));
127
+ }
128
+ }
129
+ } catch { /* location absent */ }
130
+ }
84
131
  }
85
132
  return [...names].sort();
86
133
  }
@@ -129,13 +176,13 @@ export async function resolveShareArg(arg, { cwd, home }) {
129
176
  const matches = [];
130
177
  for (const cand of nameCandidates(name, { cwd, home })) {
131
178
  if (await existsAs(cand.root, cand.isDir)) {
132
- matches.push({ ...cand, agent: 'claude-code' });
179
+ matches.push(cand);
133
180
  }
134
181
  }
135
182
  if (matches.length === 0) {
136
183
  const known = await knownNames({ cwd, home });
137
184
  const near = nearestNames(name, known);
138
- let msg = `No skill named "${name}" found here or in ~/.claude.`;
185
+ let msg = `No skill named "${name}" found here or in any known agent location.`;
139
186
  if (near.length) msg += `\nDid you mean: ${near.join(', ')}?`;
140
187
  else if (known.length) msg += `\nAvailable: ${known.slice(0, 8).join(', ')}`;
141
188
  throw new CliError(msg, 2);
@@ -253,22 +300,27 @@ export function primaryDocPath(files, { isDir, type }) {
253
300
 
254
301
  // name: frontmatter `name:` → basename (--name overrides, applied by caller).
255
302
  // description: frontmatter → first heading → first paragraph → "".
303
+ // Dialect-aware: .md/.mdc/.prompt.md use YAML frontmatter; .toml is gemini.
256
304
  export async function inferMetadata({ root, isDir, type, agent, files }) {
257
305
  let fm = {};
258
306
  let body = '';
259
307
  const docRel = primaryDocPath(files, { isDir, type });
260
308
  if (docRel) {
261
309
  const docAbs = isDir ? path.join(root, ...docRel.split('/')) : root;
262
- if (docAbs.endsWith('.md') || docRel.endsWith('.md')) {
263
- try {
264
- const text = await readFile(docAbs, 'utf8');
310
+ try {
311
+ const text = await readFile(docAbs, 'utf8');
312
+ if (docRel.endsWith('.toml')) {
313
+ const parsed = parseGeminiToml(text);
314
+ if (parsed.description) fm.description = parsed.description;
315
+ body = parsed.body ?? '';
316
+ } else if (/\.(md|mdc)$/.test(docRel)) {
265
317
  const parsed = parseFrontmatter(text);
266
318
  fm = parsed.data;
267
319
  body = parsed.body;
268
- } catch { /* unreadable doc → fall back to basenames */ }
269
- }
320
+ }
321
+ } catch { /* unreadable doc → fall back to basenames */ }
270
322
  }
271
- const base = path.basename(root).replace(/\.[^.]+$/, '');
323
+ const base = artifactBaseName(path.basename(root));
272
324
  const name = (fm.name && String(fm.name).trim()) || base;
273
325
  const description = (fm.description && String(fm.description).trim()) || firstHeadingOrParagraph(body) || '';
274
326
  const dependencies = [];
package/src/gh.js CHANGED
@@ -1,30 +1,46 @@
1
1
  // The gh helper — the ONLY module (besides the clipboard) that touches
2
- // child_process. Used exclusively by sender operations (share, revoke).
3
- // Receivers never come through here. execFile with argument arrays only;
4
- // user input is never interpolated into a shell string (hard rule 3).
2
+ // child_process. Used by sender operations (share, revoke) and, for GitHub
3
+ // Enterprise hosts, by the receive path too: enterprise links are private by
4
+ // nature, so they ride the receiver's own gh auth never anonymous HTTPS.
5
+ // execFile with argument arrays only; user input is never interpolated into
6
+ // a shell string (hard rule 3).
5
7
  import { execFile as execFileCb } from 'node:child_process';
6
8
  import { promisify } from 'node:util';
7
9
  import { CliError, MSG } from './errors.js';
10
+ import { DEFAULT_HOST } from './source.js';
8
11
 
9
12
  const execFileP = promisify(execFileCb);
10
13
 
11
14
  // Default runner; tests inject their own to capture or forbid calls.
12
- export async function defaultGhRunner(args) {
13
- const { stdout } = await execFileP('gh', args, { maxBuffer: 32 * 1024 * 1024 });
15
+ // opts.binary returns a Buffer (repo tarballs); default is utf8 text.
16
+ export async function defaultGhRunner(args, opts = {}) {
17
+ const { stdout } = await execFileP('gh', args, {
18
+ maxBuffer: 96 * 1024 * 1024,
19
+ encoding: opts.binary ? 'buffer' : 'utf8',
20
+ });
14
21
  return stdout;
15
22
  }
16
23
 
24
+ // Extra args to aim gh at a GitHub Enterprise host.
25
+ export function hostArgs(host) {
26
+ return host && host !== DEFAULT_HOST ? ['--hostname', host] : [];
27
+ }
28
+
17
29
  // Run `gh api ...` and map the usual failure modes onto exit-code-2 guidance.
18
30
  export function makeGhApi(runner = defaultGhRunner) {
19
- return async function ghApi(args) {
31
+ return async function ghApi(args, opts = {}) {
20
32
  try {
21
- return await runner(['api', ...args]);
33
+ return await runner(['api', ...args], opts);
22
34
  } catch (err) {
23
35
  if (err instanceof CliError) throw err;
24
- if (err?.code === 'ENOENT') throw new CliError(MSG.ghMissing, 2);
36
+ const hostIdx = args.indexOf('--hostname');
37
+ const host = hostIdx !== -1 ? args[hostIdx + 1] : null;
38
+ if (err?.code === 'ENOENT') {
39
+ throw new CliError(host ? enterpriseGhMsg(host) : MSG.ghMissing, 2);
40
+ }
25
41
  const stderr = String(err?.stderr ?? '');
26
42
  if (/not logged in|authentication|HTTP 401|gh auth login/i.test(stderr)) {
27
- throw new CliError(MSG.ghMissing, 2);
43
+ throw new CliError(host ? enterpriseGhMsg(host) : MSG.ghMissing, 2);
28
44
  }
29
45
  if (/HTTP 404/.test(stderr)) throw new CliError('GitHub returned 404 for that id.', 1);
30
46
  const detail = stderr.trim().split('\n')[0] || err.message;
@@ -32,3 +48,10 @@ export function makeGhApi(runner = defaultGhRunner) {
32
48
  }
33
49
  };
34
50
  }
51
+
52
+ export function enterpriseGhMsg(host) {
53
+ return (
54
+ `This needs the GitHub CLI authenticated against ${host} (GitHub Enterprise):\n` +
55
+ ` https://cli.github.com, then "gh auth login --hostname ${host}"`
56
+ );
57
+ }