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/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,29 +11,40 @@
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
27
38
 
28
- **share** โ€” package a skill and get an unlisted link (auto-copied to your clipboard):
39
+ **share** โ€” package a skill and get an unlisted link. Your clipboard receives the full **paste-and-go install one-liner**, so the receiver just pastes it into any terminal:
29
40
 
30
41
  ```sh
31
42
  skillshark share /j
32
- # โ†’ https://gist.github.com/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
43
+ # clipboard โ†’ npx skillshark install 'https://gist.github.com/8a1bc94โ€ฆ#fp=3f9a7c21'
33
44
  ```
34
45
 
46
+ (`-q` still prints the bare URL for scripts; `--json` includes both `url` and `installCommand`.)
47
+
35
48
  Accepts a name (`j`, `/j` โ€” resolved across `./.claude/skills`, `./.claude/commands`, and their `~/` equivalents) or any path. Useful flags: `--expires 30m|6h|24h|7d|30d` (advisory, default 7d), `--dry-run`, `--name`, `--force`, `--no-clipboard`, `-q` (print only the URL).
36
49
 
37
50
  **install** โ€” download, verify, preview, confirm, copy:
@@ -42,7 +55,10 @@ skillshark install <gist-id> # bare id works too
42
55
  skillshark install gh:acme/skills/review@main # any public repo path
43
56
  ```
44
57
 
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>`.
58
+ 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:
59
+
60
+ - `--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.
61
+ - `--agent <id>` โ€” install for a different tool entirely (see below).
46
62
 
47
63
  **inspect** โ€” look before you leap (writes nothing):
48
64
 
@@ -62,6 +78,49 @@ The gist dies immediately; anyone holding the link gets "deleted by the sender."
62
78
  (GitHub's anonymous API cache can serve a just-deleted gist for up to ~a minute
63
79
  before the 404 propagates everywhere.)
64
80
 
81
+ ## Cross-agent sharing (v0.2)
82
+
83
+ 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>`:
84
+
85
+ | `--agent` | Artifacts | Where they land |
86
+ |---|---|---|
87
+ | `claude-code` | skills, commands | `.claude/skills/<n>/`, `.claude/commands/<n>.md` (project or `~/`) |
88
+ | `cursor` | rules, commands | `.cursor/rules/<n>.mdc` (project), `.cursor/commands/<n>.md` |
89
+ | `codex` | prompts | `~/.codex/prompts/<n>.md` (Codex only reads global) |
90
+ | `copilot` | prompt files | `.github/prompts/<n>.prompt.md` (project) |
91
+ | `windsurf` | rules, workflows | `.windsurf/rules/<n>.md`, `.windsurf/workflows/<n>.md` (project) |
92
+ | `gemini` | commands | `.gemini/commands/<n>.toml` (TOML, project or `~/`) |
93
+ | `opencode` | commands | `.opencode/command/<n>.md`, `~/.config/opencode/command/<n>.md` |
94
+
95
+ ```sh
96
+ skillshark share draftpr # found in ~/.codex/prompts/draftpr.md
97
+ skillshark install <link> --agent cursor # lands as .cursor/commands/draftpr.md
98
+ ```
99
+
100
+ 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:
101
+
102
+ - **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.
103
+ - **Conversion is best-effort.** A skill written for one tool may assume features another doesn't have. Read the result.
104
+
105
+ Same-agent installs are always byte-verbatim โ€” conversion only happens when you cross.
106
+
107
+ ## GitHub Enterprise (v0.3)
108
+
109
+ If your company runs GitHub Enterprise (GHES or `*.ghe.com`), SkillShark works entirely inside it โ€” nothing touches public github.com:
110
+
111
+ ```sh
112
+ gh auth login --hostname ghe.corp.com # once, sender and receivers alike
113
+ skillshark share j --host ghe.corp.com # gist lives on YOUR GitHub
114
+ skillshark install 'https://ghe.corp.com/gist/<id>#fp=<hex>'
115
+ ```
116
+
117
+ - **Links carry their host.** Receivers don't need `--host` โ€” an enterprise URL routes itself. `GH_HOST` (or `SKILLSHARK_HOST`) sets the default for bare ids and `gh:owner/repo` sources.
118
+ - **The privacy property:** enterprise links are fetched exclusively through the receiver's own `gh` auth. No anonymous request ever leaves for an enterprise host (enforced by tests), and unauthenticated users get a clear `gh auth login --hostname โ€ฆ` pointer instead of a leak.
119
+ - **One honest limit:** the gists API truncates inline content at ~1 MB and enterprise receivers can't fetch around it anonymously, so enterprise *gist* shares are capped at ~900 KB encoded. Bigger skills: put them in a repo on your GHES and share `gh:owner/repo/path` with `--host`.
120
+ - `revoke` remembers which host a share went to and deletes it there.
121
+
122
+ Public github.com behavior is completely unchanged: receivers still need no account and the receive path still never invokes `gh`.
123
+
65
124
  ## Security model
66
125
 
67
126
  - **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
@@ -31,17 +33,34 @@ GLOBAL OPTIONS
31
33
  -y, --yes Skip prompts (non-interactive)
32
34
  -q, --quiet Print only the essential result (URL or path)
33
35
  --json Machine-readable output
36
+ --host <h> GitHub Enterprise hostname (default github.com; also
37
+ honors GH_HOST). Enterprise links carry their host, so
38
+ receivers usually don't need this flag.
34
39
  --no-color Disable color (NO_COLOR is also honored)
35
40
  -h, --help Show help (try: skillshark help <command>)
36
41
  -V, --version Show version
37
42
 
43
+ ENTERPRISE (privacy)
44
+ skillshark share j --host ghe.corp.com share on your GHES โ€” never leaves it
45
+ Enterprise links are fetched through YOUR gh auth (gh auth login --hostname
46
+ ghe.corp.com); no anonymous request ever touches an enterprise host.
47
+
38
48
  EXAMPLES
49
+ skillshark interactive: menus for everything below
39
50
  skillshark share /j share the "j" skill (secret gist)
40
51
  skillshark install <gist-url|id> install a shared skill
52
+ skillshark install <link> --name jmp install under a different name
53
+ skillshark install <link> --agent codex convert for another tool
41
54
  skillshark install gh:acme/skills/review install straight from a repo path
42
55
  skillshark inspect <gist-url> --cat SKILL.md
43
56
  skillshark revoke j delete the share
44
57
 
58
+ AGENTS (share from and install to)
59
+ claude-code (skills, commands) ยท cursor (rules, commands) ยท codex (prompts)
60
+ copilot (prompt files) ยท windsurf (rules, workflows) ยท gemini (commands)
61
+ opencode (commands). Cross-agent installs convert the instructions to the
62
+ target's dialect; a skill's bundled scripts can't follow it (warned loudly).
63
+
45
64
  Secret gists are unlisted, NOT private โ€” anyone with the link can read them.
46
65
  SkillShark never executes package content; install only copies files.`;
47
66
 
@@ -62,10 +81,16 @@ unlisted, not private: anyone holding it can read the gist. Undo with
62
81
  install: `skillshark install <source> โ€” download, verify, preview, confirm, copy
63
82
 
64
83
  -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)
84
+ --name <name> Install under a different name (renames the artifact
85
+ and updates its frontmatter name)
86
+ --agent <id> Target agent: claude-code | cursor | codex | copilot |
87
+ windsurf | gemini | opencode. Crossing agents converts
88
+ the artifact's instructions to the target's dialect โ€”
89
+ bundled support files can't come along (warned).
90
+ --project Install at project scope (where the agent supports it)
91
+ --global Install at user/global scope
92
+ --dir <path> Install verbatim into an explicit directory (required
93
+ for bundle packages; skips agent conventions)
69
94
  --force Overwrite an existing, differing artifact
70
95
  --allow-exec Keep executable bits (stripped by default)
71
96
  -q, --quiet Print only the installed path
@@ -94,6 +119,7 @@ const GLOBAL_FLAGS = {
94
119
  yes: { short: 'y', key: 'yes' },
95
120
  quiet: { short: 'q', key: 'quiet' },
96
121
  json: { key: 'json' },
122
+ host: { takesValue: true, key: 'host' },
97
123
  'no-color': { key: 'noColor' },
98
124
  help: { short: 'h', key: 'help' },
99
125
  version: { short: 'V', key: 'version' },
@@ -113,6 +139,7 @@ const COMMAND_FLAGS = {
113
139
  dir: { takesValue: true, key: 'dir' },
114
140
  'allow-exec': { key: 'allowExec' },
115
141
  agent: { takesValue: true, key: 'agent' },
142
+ name: { takesValue: true, key: 'name' },
116
143
  },
117
144
  inspect: {
118
145
  cat: { takesValue: true, key: 'cat' },
@@ -123,7 +150,8 @@ const COMMAND_FLAGS = {
123
150
 
124
151
  function parseArgv(argv) {
125
152
  const [first, ...rest] = argv;
126
- if (!first || first === 'help') {
153
+ if (!first) return { command: 'interactive', opts: {}, positionals: [] };
154
+ if (first === 'help') {
127
155
  return { command: 'help', topic: rest[0] ?? null, opts: {}, positionals: [] };
128
156
  }
129
157
  if (first === '--help' || first === '-h') return { command: 'help', topic: null, opts: {}, positionals: [] };
@@ -192,19 +220,10 @@ async function main() {
192
220
  return 0;
193
221
  }
194
222
 
195
- const known = new Set(['share', 'install', 'inspect', 'revoke']);
223
+ const known = new Set(['share', 'install', 'inspect', 'revoke', 'interactive']);
196
224
  if (!known.has(parsed.command)) {
197
225
  throw new CliError(`Unknown command "${parsed.command}". Commands: share, install, inspect, revoke. Try: skillshark --help`, 2);
198
226
  }
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
227
  if (parsed.positionals.length > 1) {
209
228
  throw new CliError(`Too many arguments: ${parsed.positionals.slice(1).join(' ')}`, 2);
210
229
  }
@@ -223,6 +242,24 @@ async function main() {
223
242
  ghApi: makeGhApi(),
224
243
  clipboard: (text) => copyToClipboard(text),
225
244
  };
245
+ if (effectiveTTY) await attachSpinner(ui);
246
+
247
+ // no command (TTY) โ†’ the interactive session; piped โ†’ help text
248
+ if (parsed.command === 'interactive') {
249
+ if (!effectiveTTY) {
250
+ ui.out(HELP);
251
+ return 0;
252
+ }
253
+ return runInteractive(deps);
254
+ }
255
+
256
+ const arg = parsed.positionals[0];
257
+ if (!arg) {
258
+ // bare subcommand in a TTY โ†’ that command's wizard; piped โ†’ usage error
259
+ if (effectiveTTY) return runInteractive(deps, parsed.command);
260
+ const noun = parsed.command === 'share' ? '<path|name>' : parsed.command === 'revoke' ? '<id|name>' : '<source>';
261
+ throw new CliError(`Usage: skillshark ${parsed.command} ${noun}. Try: skillshark help ${parsed.command}`, 2);
262
+ }
226
263
 
227
264
  switch (parsed.command) {
228
265
  case 'share':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshark",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
+ }