skillshark 0.1.0 โ†’ 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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # SkillShark ๐Ÿฆˆ
2
2
 
3
+ **[holy-coders.github.io/skillshark](https://holy-coders.github.io/skillshark/)** ยท [npm](https://www.npmjs.com/package/skillshark)
4
+
3
5
  **Share an agent skill like you'd share a file.** SkillShark packages a Claude Code skill (or command) into a secret GitHub gist and hands you an unlisted, self-verifying link; the receiver installs it with one command and zero setup โ€” no GitHub account, no server, no registry.
4
6
 
5
7
  > โš ๏ธ **Secret gists are unlisted, NOT private โ€” anyone with the link can read them.**
@@ -9,18 +11,27 @@
9
11
 
10
12
  ## Install
11
13
 
12
- Zero-install:
14
+ Zero-install (stable, from npm):
13
15
 
14
16
  ```sh
15
17
  npx skillshark install <link>
16
18
  ```
17
19
 
18
- Or globally:
20
+ Want the **beta** โ€” whatever is on `main` right now, ahead of the npm release?
21
+
22
+ ```sh
23
+ npx github:Holy-Coders/skillshark install <link>
24
+ ```
25
+
26
+ Both forms take the exact same commands and flags; the `github:` one just builds from the repo's latest commit instead of the published package. Or install globally:
19
27
 
20
28
  ```sh
21
- npm install -g skillshark
29
+ npm install -g skillshark # stable
30
+ npm install -g github:Holy-Coders/skillshark # beta from main
22
31
  ```
23
32
 
33
+ Once it's on your PATH, running bare **`skillshark`** opens an interactive session โ€” pick a local skill to share from a menu, paste a link to install, inspect, or revoke, all guided with previews and spinners. Every flag-driven form below works too (and is what you want in scripts/CI).
34
+
24
35
  Requirements: Node โ‰ฅ 20. **Senders** also need the [GitHub CLI](https://cli.github.com) authenticated (`gh auth login`). **Receivers need nothing** โ€” public links are fetched over plain anonymous HTTPS.
25
36
 
26
37
  ## The four commands
@@ -42,7 +53,10 @@ skillshark install <gist-id> # bare id works too
42
53
  skillshark install gh:acme/skills/review@main # any public repo path
43
54
  ```
44
55
 
45
- Skills land in `.claude/skills/<name>/`, commands in `.claude/commands/<name>.md` (project scope when the cwd has `.claude/` or `.git`, else `--project`/`--global`/`--dir`). Useful flags: `--yes`, `--force`, `--allow-exec`, `--dir <path>`.
56
+ Skills land in `.claude/skills/<name>/`, commands in `.claude/commands/<name>.md` (project scope when the cwd looks like a project, else `--project`/`--global`/`--dir`). Useful flags: `--yes`, `--force`, `--allow-exec`, `--dir <path>`, and:
57
+
58
+ - `--name <name>` โ€” install under a different name. The directory/filename changes and the artifact's frontmatter `name:` is rewritten to match, so two variants of the same skill can live side by side.
59
+ - `--agent <id>` โ€” install for a different tool entirely (see below).
46
60
 
47
61
  **inspect** โ€” look before you leap (writes nothing):
48
62
 
@@ -62,6 +76,32 @@ The gist dies immediately; anyone holding the link gets "deleted by the sender."
62
76
  (GitHub's anonymous API cache can serve a just-deleted gist for up to ~a minute
63
77
  before the 404 propagates everywhere.)
64
78
 
79
+ ## Cross-agent sharing (v0.2)
80
+
81
+ SkillShark speaks seven tools' on-disk dialects. You can **share from** any of them (bare names resolve across all of these locations) and **install to** any of them with `--agent <id>`:
82
+
83
+ | `--agent` | Artifacts | Where they land |
84
+ |---|---|---|
85
+ | `claude-code` | skills, commands | `.claude/skills/<n>/`, `.claude/commands/<n>.md` (project or `~/`) |
86
+ | `cursor` | rules, commands | `.cursor/rules/<n>.mdc` (project), `.cursor/commands/<n>.md` |
87
+ | `codex` | prompts | `~/.codex/prompts/<n>.md` (Codex only reads global) |
88
+ | `copilot` | prompt files | `.github/prompts/<n>.prompt.md` (project) |
89
+ | `windsurf` | rules, workflows | `.windsurf/rules/<n>.md`, `.windsurf/workflows/<n>.md` (project) |
90
+ | `gemini` | commands | `.gemini/commands/<n>.toml` (TOML, project or `~/`) |
91
+ | `opencode` | commands | `.opencode/command/<n>.md`, `~/.config/opencode/command/<n>.md` |
92
+
93
+ ```sh
94
+ skillshark share draftpr # found in ~/.codex/prompts/draftpr.md
95
+ skillshark install <link> --agent cursor # lands as .cursor/commands/draftpr.md
96
+ ```
97
+
98
+ Crossing agents **converts** the artifact: the instructions (frontmatter + body) are re-rendered in the target's dialect โ€” YAML frontmatter for Claude/Copilot/opencode, `.mdc` for Cursor rules, TOML `prompt`/`description` for Gemini, plain markdown where the tool wants it. Two honest limits, stated loudly at install time:
99
+
100
+ - **Bundled files don't cross.** A Claude skill's `scripts/` and reference files have no equivalent elsewhere; converting installs the instructions only and names every file left behind.
101
+ - **Conversion is best-effort.** A skill written for one tool may assume features another doesn't have. Read the result.
102
+
103
+ Same-agent installs are always byte-verbatim โ€” conversion only happens when you cross.
104
+
65
105
  ## Security model
66
106
 
67
107
  - **SkillShark never executes package content.** Install = copy files. No postinstall hooks, no scripts, ever.
package/bin/skillshark.js CHANGED
@@ -5,15 +5,17 @@ import os from 'node:os';
5
5
  import { CliError } from '../src/errors.js';
6
6
  import { VERSION } from '../src/version.js';
7
7
  import { getConfigDir } from '../src/config.js';
8
- import { makeUi, realPrompts } from '../src/ui.js';
8
+ import { makeUi, realPrompts, attachSpinner } from '../src/ui.js';
9
9
  import { makeGhApi } from '../src/gh.js';
10
10
  import { copyToClipboard } from '../src/clipboard.js';
11
11
  import { runShare, runRevoke } from '../src/share.js';
12
12
  import { runInstall, runInspect } from '../src/install.js';
13
+ import { runInteractive } from '../src/interactive.js';
13
14
 
14
15
  const HELP = `skillshark โ€” share agent skills like files
15
16
 
16
17
  USAGE
18
+ skillshark interactive session (pick, share, install โ€” guided)
17
19
  skillshark <command> [options]
18
20
 
19
21
  COMMANDS
@@ -36,12 +38,21 @@ GLOBAL OPTIONS
36
38
  -V, --version Show version
37
39
 
38
40
  EXAMPLES
41
+ skillshark interactive: menus for everything below
39
42
  skillshark share /j share the "j" skill (secret gist)
40
43
  skillshark install <gist-url|id> install a shared skill
44
+ skillshark install <link> --name jmp install under a different name
45
+ skillshark install <link> --agent codex convert for another tool
41
46
  skillshark install gh:acme/skills/review install straight from a repo path
42
47
  skillshark inspect <gist-url> --cat SKILL.md
43
48
  skillshark revoke j delete the share
44
49
 
50
+ AGENTS (share from and install to)
51
+ claude-code (skills, commands) ยท cursor (rules, commands) ยท codex (prompts)
52
+ copilot (prompt files) ยท windsurf (rules, workflows) ยท gemini (commands)
53
+ opencode (commands). Cross-agent installs convert the instructions to the
54
+ target's dialect; a skill's bundled scripts can't follow it (warned loudly).
55
+
45
56
  Secret gists are unlisted, NOT private โ€” anyone with the link can read them.
46
57
  SkillShark never executes package content; install only copies files.`;
47
58
 
@@ -62,10 +73,16 @@ unlisted, not private: anyone holding it can read the gist. Undo with
62
73
  install: `skillshark install <source> โ€” download, verify, preview, confirm, copy
63
74
 
64
75
  -y, --yes Install without prompting (documented as dangerous)
65
- --project Install into ./.claude/... (default when cwd is a project)
66
- --global Install into ~/.claude/... (all projects)
67
- --dir <path> Install into an explicit directory (required for
68
- prompt/bundle packages; overrides agent detection)
76
+ --name <name> Install under a different name (renames the artifact
77
+ and updates its frontmatter name)
78
+ --agent <id> Target agent: claude-code | cursor | codex | copilot |
79
+ windsurf | gemini | opencode. Crossing agents converts
80
+ the artifact's instructions to the target's dialect โ€”
81
+ bundled support files can't come along (warned).
82
+ --project Install at project scope (where the agent supports it)
83
+ --global Install at user/global scope
84
+ --dir <path> Install verbatim into an explicit directory (required
85
+ for bundle packages; skips agent conventions)
69
86
  --force Overwrite an existing, differing artifact
70
87
  --allow-exec Keep executable bits (stripped by default)
71
88
  -q, --quiet Print only the installed path
@@ -113,6 +130,7 @@ const COMMAND_FLAGS = {
113
130
  dir: { takesValue: true, key: 'dir' },
114
131
  'allow-exec': { key: 'allowExec' },
115
132
  agent: { takesValue: true, key: 'agent' },
133
+ name: { takesValue: true, key: 'name' },
116
134
  },
117
135
  inspect: {
118
136
  cat: { takesValue: true, key: 'cat' },
@@ -123,7 +141,8 @@ const COMMAND_FLAGS = {
123
141
 
124
142
  function parseArgv(argv) {
125
143
  const [first, ...rest] = argv;
126
- if (!first || first === 'help') {
144
+ if (!first) return { command: 'interactive', opts: {}, positionals: [] };
145
+ if (first === 'help') {
127
146
  return { command: 'help', topic: rest[0] ?? null, opts: {}, positionals: [] };
128
147
  }
129
148
  if (first === '--help' || first === '-h') return { command: 'help', topic: null, opts: {}, positionals: [] };
@@ -192,19 +211,10 @@ async function main() {
192
211
  return 0;
193
212
  }
194
213
 
195
- const known = new Set(['share', 'install', 'inspect', 'revoke']);
214
+ const known = new Set(['share', 'install', 'inspect', 'revoke', 'interactive']);
196
215
  if (!known.has(parsed.command)) {
197
216
  throw new CliError(`Unknown command "${parsed.command}". Commands: share, install, inspect, revoke. Try: skillshark --help`, 2);
198
217
  }
199
- if (parsed.opts.agent && parsed.opts.agent !== 'claude-code') {
200
- throw new CliError(`Only --agent claude-code is supported in v0.1 (got "${parsed.opts.agent}").`, 2);
201
- }
202
-
203
- const arg = parsed.positionals[0];
204
- if (!arg) {
205
- const noun = parsed.command === 'share' ? '<path|name>' : parsed.command === 'revoke' ? '<id|name>' : '<source>';
206
- throw new CliError(`Usage: skillshark ${parsed.command} ${noun}. Try: skillshark help ${parsed.command}`, 2);
207
- }
208
218
  if (parsed.positionals.length > 1) {
209
219
  throw new CliError(`Too many arguments: ${parsed.positionals.slice(1).join(' ')}`, 2);
210
220
  }
@@ -223,6 +233,24 @@ async function main() {
223
233
  ghApi: makeGhApi(),
224
234
  clipboard: (text) => copyToClipboard(text),
225
235
  };
236
+ if (effectiveTTY) await attachSpinner(ui);
237
+
238
+ // no command (TTY) โ†’ the interactive session; piped โ†’ help text
239
+ if (parsed.command === 'interactive') {
240
+ if (!effectiveTTY) {
241
+ ui.out(HELP);
242
+ return 0;
243
+ }
244
+ return runInteractive(deps);
245
+ }
246
+
247
+ const arg = parsed.positionals[0];
248
+ if (!arg) {
249
+ // bare subcommand in a TTY โ†’ that command's wizard; piped โ†’ usage error
250
+ if (effectiveTTY) return runInteractive(deps, parsed.command);
251
+ const noun = parsed.command === 'share' ? '<path|name>' : parsed.command === 'revoke' ? '<id|name>' : '<source>';
252
+ throw new CliError(`Usage: skillshark ${parsed.command} ${noun}. Try: skillshark help ${parsed.command}`, 2);
253
+ }
226
254
 
227
255
  switch (parsed.command) {
228
256
  case 'share':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshark",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Share agent skills like files โ€” secret gists out, safe verified installs in. No server; GitHub is the backend.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,14 @@
11
11
  "src",
12
12
  "README.md"
13
13
  ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Holy-Coders/skillshark.git"
17
+ },
18
+ "homepage": "https://holy-coders.github.io/skillshark/",
19
+ "bugs": {
20
+ "url": "https://github.com/Holy-Coders/skillshark/issues"
21
+ },
14
22
  "keywords": [
15
23
  "claude-code",
16
24
  "agent-skills",
@@ -19,7 +27,7 @@
19
27
  "cli",
20
28
  "sharing"
21
29
  ],
22
- "author": "Aaron Turkel",
30
+ "author": "Holy Coders",
23
31
  "engines": {
24
32
  "node": ">=20"
25
33
  },
package/src/agents.js ADDED
@@ -0,0 +1,336 @@
1
+ // The adapter registry: the ONLY place agent-specific knowledge lives.
2
+ // Each adapter encodes one tool's on-disk conventions (verified against the
3
+ // tools' docs, June 2026) and how to render a canonical artifact into its
4
+ // dialect. Cross-agent installs are honest best-effort conversions: markdown
5
+ // instructions travel; bundled support files cannot leave a Claude skill.
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { CliError } from './errors.js';
9
+
10
+ export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
11
+
12
+ // canonical = { name, description, body } โ€” body is the primary document
13
+ // with its source-dialect frontmatter stripped.
14
+
15
+ // --- dialect helpers ---------------------------------------------------------
16
+
17
+ function yamlFront(fields) {
18
+ const lines = Object.entries(fields)
19
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
20
+ .map(([k, v]) => `${k}: ${String(v)}`);
21
+ if (!lines.length) return '';
22
+ return `---\n${lines.join('\n')}\n---\n\n`;
23
+ }
24
+
25
+ // TOML basic strings: escape backslashes and quotes so the parsed prompt is
26
+ // byte-identical to the source body.
27
+ function tomlBasicString(s) {
28
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
29
+ }
30
+
31
+ function tomlMultiline(s) {
32
+ const escaped = s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
33
+ return `"""\n${escaped}\n"""`;
34
+ }
35
+
36
+ export function renderGeminiToml({ description, body }) {
37
+ let out = '';
38
+ if (description) out += `description = ${tomlBasicString(description)}\n\n`;
39
+ out += `prompt = ${tomlMultiline(body.replace(/\n$/, ''))}\n`;
40
+ return out;
41
+ }
42
+
43
+ // Parse the gemini TOML dialect (our generated shape + the common hand-written
44
+ // variants: basic/literal multiline strings and single-line prompts).
45
+ export function parseGeminiToml(text) {
46
+ const out = { description: '', body: null };
47
+ const desc = text.match(/^[ \t]*description[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"[ \t]*$/m);
48
+ if (desc) out.description = desc[1].replace(/\\(["\\])/g, '$1');
49
+
50
+ let m = text.match(/^[ \t]*prompt[ \t]*=[ \t]*"""\n?([\s\S]*?)"""[ \t]*$/m);
51
+ if (m) {
52
+ out.body = m[1].replace(/""\\"/g, '"""').replace(/\\\\/g, '\\').replace(/\n$/, '');
53
+ return out;
54
+ }
55
+ m = text.match(/^[ \t]*prompt[ \t]*=[ \t]*'''\n?([\s\S]*?)'''[ \t]*$/m);
56
+ if (m) {
57
+ out.body = m[1].replace(/\n$/, '');
58
+ return out;
59
+ }
60
+ m = text.match(/^[ \t]*prompt[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"[ \t]*$/m);
61
+ if (m) {
62
+ out.body = m[1].replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\(["\\])/g, '$1');
63
+ return out;
64
+ }
65
+ return out; // body null โ†’ caller decides how honest to be
66
+ }
67
+
68
+ // Rewrite (or leave alone) the `name:` value in a YAML frontmatter block.
69
+ export function rewriteFrontmatterName(text, newName) {
70
+ if (!text.startsWith('---')) return text;
71
+ const end = text.indexOf('\n---', 3);
72
+ if (end === -1) return text;
73
+ const head = text.slice(0, end);
74
+ if (!/^name[ \t]*:/m.test(head.slice(4))) return text;
75
+ const newHead = head.replace(/^(name[ \t]*:[ \t]*).*$/m, `$1${newName}`);
76
+ return newHead + text.slice(end);
77
+ }
78
+
79
+ // --- the registry -------------------------------------------------------------
80
+
81
+ // Adapter shape:
82
+ // label human name
83
+ // detect({cwd, home}) is this tool present here?
84
+ // locations share-side lookup spots for a bare <name>
85
+ // { kind, scope, rel(name) } rel is relative to cwd (project) or home (global)
86
+ // mapKind(type) incoming artifact type โ†’ this agent's kind (null = can't)
87
+ // scopes(kind) 'both' | 'project' | 'global'
88
+ // targetRel(kind, name) install path segments, relative to the scope root
89
+ // container(kind) 'dir' | 'file'
90
+ // render(kind, canonical) content for a converted install
91
+ // parse(content, ext) native file โ†’ { description, body } (canonical-ish)
92
+ const mdParse = (content) => {
93
+ // lazy import avoided: tiny local frontmatter read
94
+ const fm = {};
95
+ let body = content;
96
+ if (content.startsWith('---')) {
97
+ const end = content.indexOf('\n---', 3);
98
+ if (end !== -1) {
99
+ for (const line of content.slice(4, end).split('\n')) {
100
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
101
+ if (m) fm[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
102
+ }
103
+ body = content.slice(end + 4).replace(/^\n/, '');
104
+ }
105
+ }
106
+ return { name: fm.name, description: fm.description ?? '', body };
107
+ };
108
+
109
+ export const AGENTS = {
110
+ 'claude-code': {
111
+ label: 'Claude Code',
112
+ detect: ({ cwd, home }) =>
113
+ existsSync(path.join(cwd, '.claude')) || existsSync(path.join(home, '.claude')),
114
+ locations: [
115
+ { kind: 'skill', scope: 'project', container: 'dir', rel: (n) => ['.claude', 'skills', n] },
116
+ { kind: 'command', scope: 'project', container: 'file', rel: (n) => ['.claude', 'commands', `${n}.md`] },
117
+ { kind: 'skill', scope: 'global', container: 'dir', rel: (n) => ['.claude', 'skills', n] },
118
+ { kind: 'command', scope: 'global', container: 'file', rel: (n) => ['.claude', 'commands', `${n}.md`] },
119
+ ],
120
+ mapKind: (type) => (type === 'skill' || type === 'rule' ? 'skill' : type === 'bundle' ? null : 'command'),
121
+ scopes: () => 'both',
122
+ container: (kind) => (kind === 'skill' ? 'dir' : 'file'),
123
+ targetRel: (kind, name) =>
124
+ kind === 'skill' ? ['.claude', 'skills', name] : ['.claude', 'commands', `${name}.md`],
125
+ render: (kind, { name, description, body }) => {
126
+ if (kind === 'skill') {
127
+ return { filename: 'SKILL.md', content: yamlFront({ name, description }) + body };
128
+ }
129
+ return { filename: `${name}.md`, content: yamlFront({ description }) + body };
130
+ },
131
+ parse: mdParse,
132
+ },
133
+
134
+ cursor: {
135
+ label: 'Cursor',
136
+ detect: ({ cwd, home }) =>
137
+ existsSync(path.join(cwd, '.cursor')) || existsSync(path.join(home, '.cursor')),
138
+ locations: [
139
+ { kind: 'rule', scope: 'project', container: 'file', rel: (n) => ['.cursor', 'rules', `${n}.mdc`] },
140
+ { kind: 'command', scope: 'project', container: 'file', rel: (n) => ['.cursor', 'commands', `${n}.md`] },
141
+ { kind: 'command', scope: 'global', container: 'file', rel: (n) => ['.cursor', 'commands', `${n}.md`] },
142
+ ],
143
+ mapKind: (type) => (type === 'bundle' ? null : type === 'rule' ? 'rule' : 'command'),
144
+ scopes: (kind) => (kind === 'rule' ? 'project' : 'both'),
145
+ container: () => 'file',
146
+ targetRel: (kind, name) =>
147
+ kind === 'rule' ? ['.cursor', 'rules', `${name}.mdc`] : ['.cursor', 'commands', `${name}.md`],
148
+ render: (kind, { name, description, body }) => {
149
+ if (kind === 'rule') {
150
+ return { filename: `${name}.mdc`, content: yamlFront({ description, alwaysApply: 'false' }) + body };
151
+ }
152
+ return { filename: `${name}.md`, content: body };
153
+ },
154
+ parse: mdParse,
155
+ },
156
+
157
+ codex: {
158
+ label: 'Codex CLI',
159
+ detect: ({ cwd, home }) =>
160
+ existsSync(path.join(cwd, '.codex')) || existsSync(path.join(home, '.codex')),
161
+ locations: [
162
+ { kind: 'prompt', scope: 'global', container: 'file', rel: (n) => ['.codex', 'prompts', `${n}.md`] },
163
+ ],
164
+ mapKind: (type) => (type === 'bundle' ? null : 'prompt'),
165
+ scopes: () => 'global', // Codex scans only ~/.codex/prompts (top-level)
166
+ container: () => 'file',
167
+ targetRel: (kind, name) => ['.codex', 'prompts', `${name}.md`],
168
+ render: (kind, { description, body, name }) => ({
169
+ filename: `${name}.md`,
170
+ content: yamlFront({ description }) + body,
171
+ }),
172
+ parse: mdParse,
173
+ },
174
+
175
+ copilot: {
176
+ label: 'GitHub Copilot',
177
+ detect: ({ cwd }) =>
178
+ existsSync(path.join(cwd, '.github', 'prompts')) ||
179
+ existsSync(path.join(cwd, '.github', 'copilot-instructions.md')) ||
180
+ existsSync(path.join(cwd, '.github', 'instructions')),
181
+ locations: [
182
+ { kind: 'prompt', scope: 'project', container: 'file', rel: (n) => ['.github', 'prompts', `${n}.prompt.md`] },
183
+ ],
184
+ mapKind: (type) => (type === 'bundle' ? null : 'prompt'),
185
+ scopes: () => 'project',
186
+ container: () => 'file',
187
+ targetRel: (kind, name) => ['.github', 'prompts', `${name}.prompt.md`],
188
+ render: (kind, { description, body, name }) => ({
189
+ filename: `${name}.prompt.md`,
190
+ content: yamlFront({ description }) + body,
191
+ }),
192
+ parse: mdParse,
193
+ },
194
+
195
+ windsurf: {
196
+ label: 'Windsurf',
197
+ detect: ({ cwd, home }) =>
198
+ existsSync(path.join(cwd, '.windsurf')) ||
199
+ existsSync(path.join(home, '.codeium', 'windsurf')),
200
+ locations: [
201
+ { kind: 'rule', scope: 'project', container: 'file', rel: (n) => ['.windsurf', 'rules', `${n}.md`] },
202
+ { kind: 'workflow', scope: 'project', container: 'file', rel: (n) => ['.windsurf', 'workflows', `${n}.md`] },
203
+ ],
204
+ mapKind: (type) => (type === 'bundle' ? null : type === 'rule' ? 'rule' : 'workflow'),
205
+ scopes: () => 'project',
206
+ container: () => 'file',
207
+ targetRel: (kind, name) =>
208
+ kind === 'rule' ? ['.windsurf', 'rules', `${name}.md`] : ['.windsurf', 'workflows', `${name}.md`],
209
+ render: (kind, { description, body, name }) => {
210
+ if (kind === 'rule') return { filename: `${name}.md`, content: body };
211
+ return { filename: `${name}.md`, content: yamlFront({ description }) + body };
212
+ },
213
+ parse: mdParse,
214
+ },
215
+
216
+ gemini: {
217
+ label: 'Gemini CLI',
218
+ detect: ({ cwd, home }) =>
219
+ existsSync(path.join(cwd, '.gemini')) || existsSync(path.join(home, '.gemini')),
220
+ locations: [
221
+ { kind: 'command', scope: 'project', container: 'file', rel: (n) => ['.gemini', 'commands', `${n}.toml`] },
222
+ { kind: 'command', scope: 'global', container: 'file', rel: (n) => ['.gemini', 'commands', `${n}.toml`] },
223
+ ],
224
+ mapKind: (type) => (type === 'bundle' ? null : 'command'),
225
+ scopes: () => 'both',
226
+ container: () => 'file',
227
+ targetRel: (kind, name) => ['.gemini', 'commands', `${name}.toml`],
228
+ render: (kind, canonical) => ({
229
+ filename: `${canonical.name}.toml`,
230
+ content: renderGeminiToml(canonical),
231
+ }),
232
+ parse: (content, ext) => {
233
+ if (ext === '.toml') {
234
+ const parsed = parseGeminiToml(content);
235
+ if (parsed.body === null) {
236
+ throw new CliError(
237
+ "Couldn't read a prompt out of this Gemini command โ€” install it unconverted with --agent gemini.",
238
+ 1,
239
+ );
240
+ }
241
+ return { description: parsed.description, body: parsed.body };
242
+ }
243
+ return mdParse(content);
244
+ },
245
+ },
246
+
247
+ opencode: {
248
+ label: 'opencode',
249
+ detect: ({ cwd, home }) =>
250
+ existsSync(path.join(cwd, '.opencode')) ||
251
+ existsSync(path.join(home, '.config', 'opencode')),
252
+ locations: [
253
+ { kind: 'command', scope: 'project', container: 'file', rel: (n) => ['.opencode', 'command', `${n}.md`] },
254
+ { kind: 'command', scope: 'global', container: 'file', rel: (n) => ['.config', 'opencode', 'command', `${n}.md`] },
255
+ ],
256
+ mapKind: (type) => (type === 'bundle' ? null : 'command'),
257
+ scopes: () => 'both',
258
+ container: () => 'file',
259
+ targetRel: (kind, name, scope) =>
260
+ scope === 'global'
261
+ ? ['.config', 'opencode', 'command', `${name}.md`]
262
+ : ['.opencode', 'command', `${name}.md`],
263
+ render: (kind, { description, body, name }) => ({
264
+ filename: `${name}.md`,
265
+ content: yamlFront({ description }) + body,
266
+ }),
267
+ parse: mdParse,
268
+ },
269
+ };
270
+
271
+ export const AGENT_IDS = Object.keys(AGENTS);
272
+
273
+ export function getAgent(id) {
274
+ const a = AGENTS[id];
275
+ if (!a) {
276
+ throw new CliError(`Unknown agent "${id}". Supported: ${AGENT_IDS.join(', ')}.`, 2);
277
+ }
278
+ return a;
279
+ }
280
+
281
+ export function detectAgents(ctx) {
282
+ return AGENT_IDS.filter((id) => AGENTS[id].detect(ctx));
283
+ }
284
+
285
+ // Explicit-path classification: which agent convention does this path follow?
286
+ export function classifyByConvention(absPath, isDir) {
287
+ const norm = absPath.split(path.sep).join('/');
288
+ const rules = [
289
+ [/\/\.claude\/skills\/[^/]+$/, true, 'skill', 'claude-code'],
290
+ [/\/\.claude\/commands\/[^/]+\.md$/, false, 'command', 'claude-code'],
291
+ [/\/\.cursor\/rules\/[^/]+\.mdc$/, false, 'rule', 'cursor'],
292
+ [/\/\.cursor\/commands\/[^/]+\.md$/, false, 'command', 'cursor'],
293
+ [/\/\.codex\/prompts\/[^/]+\.md$/, false, 'prompt', 'codex'],
294
+ [/\/\.github\/prompts\/[^/]+\.prompt\.md$/, false, 'prompt', 'copilot'],
295
+ [/\/\.windsurf\/rules\/[^/]+\.md$/, false, 'rule', 'windsurf'],
296
+ [/\/\.windsurf\/workflows\/[^/]+\.md$/, false, 'workflow', 'windsurf'],
297
+ [/\/\.gemini\/commands\/[^/]+\.toml$/, false, 'command', 'gemini'],
298
+ [/\/(\.opencode|opencode)\/command\/[^/]+\.md$/, false, 'command', 'opencode'],
299
+ ];
300
+ for (const [re, wantDir, type, agent] of rules) {
301
+ if (wantDir === isDir && re.test(norm)) return { type, agent };
302
+ }
303
+ return null;
304
+ }
305
+
306
+ // Strip an artifact filename down to its bare name (handles ".prompt.md").
307
+ export function artifactBaseName(filename) {
308
+ return filename.replace(/\.prompt\.md$/, '').replace(/\.[^.]+$/, '');
309
+ }
310
+
311
+ // Extract the canonical document from a verified package: the primary doc,
312
+ // parsed with the SOURCE agent's dialect.
313
+ export function extractCanonical(manifest, readFileSyncish) {
314
+ const files = manifest.files;
315
+ let primary =
316
+ files.find((f) => f.path === 'SKILL.md') ??
317
+ (files.length === 1 ? files[0] : files.find((f) => /\.(md|mdc|toml)$/.test(f.path) && !f.path.includes('/')));
318
+ if (!primary) {
319
+ throw new CliError(
320
+ `This package has no convertible primary document. Install it natively${manifest.agent ? ` (--agent ${manifest.agent})` : ''} or with --dir.`,
321
+ 2,
322
+ );
323
+ }
324
+ const content = readFileSyncish(primary.path);
325
+ const ext = path.extname(primary.path);
326
+ const sourceAgent = AGENTS[manifest.agent] ?? AGENTS['claude-code'];
327
+ const parsed = sourceAgent.parse(content, ext);
328
+ const dropped = files.filter((f) => f.path !== primary.path).map((f) => f.path);
329
+ return {
330
+ name: manifest.name,
331
+ description: (parsed.description || manifest.description || '').trim(),
332
+ body: parsed.body.replace(/^\n+/, ''),
333
+ primaryPath: primary.path,
334
+ dropped,
335
+ };
336
+ }