skillrepo 4.0.0 → 4.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
@@ -100,7 +100,19 @@ and just write the config + gitignore.
100
100
  "skipped": ["..."],
101
101
  "failed": [{ "path": "...", "reason": "..." }]
102
102
  },
103
- "sessionSync": { "action": "installed|unchanged|skipped|failed|not-applicable", "path": "..." },
103
+ "sessionSync": {
104
+ "action": "installed|unchanged|skipped|failed|not-applicable",
105
+ "path": "...",
106
+ "cohortHooks": [
107
+ {
108
+ "vendorKey": "cursor|gemini|codex|copilot",
109
+ "displayName": "...",
110
+ "path": "~/.cursor/hooks.json",
111
+ "action": "installed|updated|unchanged|failed",
112
+ "reason": "..."
113
+ }
114
+ ]
115
+ },
104
116
  "sync": {
105
117
  "added": 0,
106
118
  "updated": 0,
@@ -114,6 +126,7 @@ and just write the config + gitignore.
114
126
 
115
127
  Field notes:
116
128
  - `vendors` is the resolved canonical-key list, NOT the raw `--agent` input. `--agent agents` produces every cohort vendor (cursor, windsurf, gemini, codex, cline, copilot); `--agent none` produces an empty array.
129
+ - `sessionSync.cohortHooks[]` reports per-vendor outcomes for the auto-refresh hooks installed alongside the Claude Code SessionStart hook (one entry per cohort vendor with a non-null `agentHook` registry spec — Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). `reason` is present only when `action: "failed"`. Empty array when `--no-session-sync` was passed or no cohort vendor was selected.
117
130
  - `sync.fullSync` is `true` for first-time syncs (no prior `.last-sync`), `false` for delta syncs, and `null` only on synthesized-failure summaries when the network call never completed (also signals via `sync.failureReason`).
118
131
  - `sync.failureReason: string` is present only when the first sync failed but the rest of init succeeded — config is still saved; user should re-run `skillrepo update`.
119
132
 
@@ -137,7 +150,7 @@ or a vendor name like `cursor`) to bypass the picker entirely.
137
150
  ### `update` — sync your library
138
151
 
139
152
  ```sh
140
- skillrepo update [--global] [--agent <list>] [--json]
153
+ skillrepo update [--global] [--agent <list>] [--json] [--silent]
141
154
  ```
142
155
 
143
156
  Pulls the latest state of your library from the server using a delta
@@ -146,6 +159,16 @@ from the library, and skips skills that are unchanged. Uses ETag
146
159
  caching so repeat runs return `304 Not Modified` when nothing has
147
160
  changed.
148
161
 
162
+ `--silent` suppresses normal output and writes a single `{}` line to
163
+ stdout on success. Designed for SessionStart hooks that pipe stdout
164
+ to their agent's session log — Gemini CLI specifically requires hook
165
+ stdout to be valid JSON, and the empty object satisfies that without
166
+ injecting model context. Sync progress and warnings still go to
167
+ stderr. On failure, the command exits with the appropriate non-zero
168
+ code and the error message goes to stderr (distinct from
169
+ `--session-hook`, which is Claude-Code-specific and exits 0 on every
170
+ error so a sync failure can't block a session start).
171
+
149
172
  ### `get` — fetch a single skill
150
173
 
151
174
  ```sh
@@ -207,6 +230,23 @@ Installs (or removes) a Claude Code [SessionStart hook](https://docs.claude.com/
207
230
 
208
231
  By default `skillrepo init` prompts you to install this hook. If you said no (or passed `--no-session-sync`), run `session-sync enable` later to turn it on.
209
232
 
233
+ #### Auto-refresh hooks for other agents
234
+
235
+ For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library refreshes on every session start without a separate command. Each hook invokes `npx --yes skillrepo update --silent`, so it works without a global `skillrepo` install.
236
+
237
+ | Agent | Hook config path | Notes |
238
+ |-------|------------------|-------|
239
+ | Cursor | `~/.cursor/hooks.json` (`sessionStart` event) | `timeout: 60` (seconds) |
240
+ | Gemini CLI | `~/.gemini/settings.json` (`SessionStart` event) | `matcher: "*"` group filter, `timeout: 60000` (milliseconds), entry named `skillrepo-update` |
241
+ | Codex CLI | `~/.codex/hooks.json` (`SessionStart` event) | `timeout: 60` (seconds). Codex also reads `[hooks]` from `~/.codex/config.toml`; both sources coexist — Codex merges them at runtime, so a hand-written TOML entry won't conflict with our JSON. |
242
+ | VS Code + Copilot | `~/.copilot/hooks/skillrepo-update.json` (`sessionStart` event) | `timeout: 60` (seconds). **Preview status**: GitHub currently labels Copilot's hook system as Preview; the schema may shift before GA. |
243
+
244
+ `skillrepo init` writes these alongside the Claude Code hook for every selected vendor that publishes a SessionStart-equivalent event. `--no-session-sync` skips ALL of them. `skillrepo uninstall --global` removes them surgically — other tools' entries (1Password, Snyk, your own scripts) in those config files are preserved.
245
+
246
+ **Cloud-agent runners** (e.g. GitHub Codespaces, Copilot's cloud agent) read only the committed default-branch content. Because skills sync to a per-developer, gitignored cache, those runners do not see the local skill library — same documented limitation as `.agents/skills/` placement. The auto-refresh hooks above are per-developer; they don't run in cloud runners.
247
+
248
+ Auto-refresh hooks for Windsurf and Cline are not yet supported — those agents lack a documented SessionStart-equivalent event today. Run `skillrepo update` manually in those environments.
249
+
210
250
  **Under `npx skillrepo init`, the CLI offers to install itself globally.** Session sync needs the binary at a stable absolute path (the `npx` cache path is transient and would break on the next cache eviction). Rather than skipping with a warning the way v3.1.1 did, v3.1.2 prompts during init: *"SkillRepo needs a global install to enable session sync. Install `skillrepo` globally now? (Y/n)"* — saying yes runs `npm install -g skillrepo@<version>` (pinned to the version you just invoked) and then installs the hook with the resulting absolute path. Under `--yes` the install runs without prompting; under `--no-session-sync` it's skipped entirely. If the install fails (permissions, network, registry), init prints actionable next-steps and continues; the rest of init still completes successfully.
211
251
 
212
252
  `skillrepo session-sync enable` does **not** auto-install — it's an explicit, deliberate command and assumes you already have a global install. If invoked under `npx` without a global install present, it returns a clear "install globally first" message rather than mutating your global package set.
@@ -247,6 +287,13 @@ With `--global`, also removes:
247
287
  - `mcpServers.skillrepo` from `~/.codeium/windsurf/mcp_config.json`
248
288
  - The `~/.claude/skills/` global skill cache
249
289
  - The `~/.claude/skillrepo/` directory (stored credentials + sync cache)
290
+ - The SkillRepo SessionStart entry from each cohort vendor's hook config —
291
+ `~/.cursor/hooks.json`, `~/.gemini/settings.json`, `~/.codex/hooks.json`,
292
+ and `~/.copilot/hooks/skillrepo-update.json`. Other tools' entries
293
+ (1Password, Snyk, Apiiro, the user's own hooks) and unrelated top-level
294
+ keys (theme, mcpServers, etc.) are preserved — only our entry, identified
295
+ by the canonical command `npx --yes skillrepo update --silent`, is
296
+ filtered out.
250
297
 
251
298
  Flags:
252
299
 
package/bin/skillrepo.mjs CHANGED
@@ -25,6 +25,7 @@ import { runInit } from "../src/commands/init.mjs";
25
25
  import { runUpdate } from "../src/commands/update.mjs";
26
26
  import { runGet } from "../src/commands/get.mjs";
27
27
  import { runAdd } from "../src/commands/add.mjs";
28
+ import { runPush } from "../src/commands/push.mjs";
28
29
  import { runRemove } from "../src/commands/remove.mjs";
29
30
  import { runList } from "../src/commands/list.mjs";
30
31
  import { runSearch } from "../src/commands/search.mjs";
@@ -62,6 +63,13 @@ const COMMANDS = {
62
63
  usage: "skillrepo add <@owner/name> [--global] [--agent <list>] [--json]",
63
64
  run: async (argv) => runAdd(argv),
64
65
  },
66
+ push: {
67
+ description: "Push a local skill directory to your library (create or release new version)",
68
+ usage:
69
+ "skillrepo push <path> [--version <label>] [--changelog <text>] " +
70
+ "[--idempotency-key <key>] [--json]",
71
+ run: async (argv) => runPush(argv),
72
+ },
65
73
  remove: {
66
74
  description: "Remove a skill from your library and delete it locally",
67
75
  usage: "skillrepo remove <@owner/name> [--global] [--agent <list>] [--json]",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,13 +13,19 @@
13
13
  ],
14
14
  "repository": {
15
15
  "type": "git",
16
- "url": "https://github.com/atxpace/skill-repo.git",
16
+ "url": "https://github.com/skill-repo/skill-repo.git",
17
17
  "directory": "packages/cli"
18
18
  },
19
- "keywords": ["skillrepo", "cli", "mcp", "ai-skills"],
19
+ "keywords": [
20
+ "skillrepo",
21
+ "cli",
22
+ "mcp",
23
+ "ai-skills"
24
+ ],
20
25
  "author": "SkillRepo LLC",
21
26
  "license": "SEE LICENSE IN LICENSE",
22
27
  "dependencies": {
23
- "cli-table3": "^0.6.5"
28
+ "cli-table3": "^0.6.5",
29
+ "gray-matter": "^4.0.3"
24
30
  }
25
31
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `skillrepo init` step-6 sibling for cohort SessionStart hooks (#1240).
3
+ *
4
+ * Runs ALONGSIDE `installSessionSyncHook` (the legacy Claude-Code-only
5
+ * installer). Where the Claude installer has to resolve a stable
6
+ * absolute `binaryPath` for the hook command — three branches
7
+ * (non-npx, existing global, auto-install) — the cohort installer has
8
+ * no such concern: every cohort hook is `npx --yes skillrepo update
9
+ * --silent`, which works on any host without a global install.
10
+ *
11
+ * That's why this is a sibling step, not an extension of
12
+ * `installSessionSyncHook`. The two flows share almost no logic. The
13
+ * architect review on #1240 specifically called out conflating them
14
+ * as a structural mistake — `installSessionSyncHook` is named for
15
+ * Claude Code's hook mechanism, and folding cohort logic into it
16
+ * would create a function with four irreducible branches by Phase 4
17
+ * (#1241-#1244).
18
+ *
19
+ * ## Decision tree
20
+ *
21
+ * noSessionSync → skip all cohort hooks (mirrors
22
+ * Claude path's --no-session-sync
23
+ * semantics)
24
+ * no cohort vendors selected → no-op (nothing to install)
25
+ * cohort vendors selected → install hooks for each one;
26
+ * per-vendor failures don't abort
27
+ * siblings
28
+ *
29
+ * ## Return shape
30
+ *
31
+ * { hooks: AgentHookInstallResult[] }
32
+ *
33
+ * Each entry: `{ vendorKey, displayName, path, action, reason? }`. The
34
+ * caller (init.mjs) merges these into the `sessionSync.cohortHooks`
35
+ * field of the JSON summary.
36
+ */
37
+
38
+ import { installAgentHooksForVendors } from "../lib/agent-hook-merge.mjs";
39
+ import { getAgentByKey } from "../lib/agent-registry.mjs";
40
+
41
+ /**
42
+ * @typedef {Object} CohortHookOptions
43
+ * @property {boolean} noSessionSync - True if `--no-session-sync` was
44
+ * passed. The flag covers BOTH the Claude session hook and
45
+ * the cohort hooks — semantically "skip all auto-refresh
46
+ * hooks." Pre-#1240 this was a Claude-only flag; the
47
+ * framing widened with the cohort feature.
48
+ * @property {string[]} vendors - Canonical vendor keys the user
49
+ * selected (via `--agent` or the picker). Vendors without
50
+ * an `agentHook` spec are silently skipped by the
51
+ * dispatcher; truly unknown keys surface as failures.
52
+ * @property {object} p - Init's printer (from `init.mjs:makePrinter`).
53
+ * Silenced under `--json`; otherwise renders one line per
54
+ * cohort hook installed/updated/failed.
55
+ */
56
+
57
+ /**
58
+ * Run the cohort sibling step. Always returns a result; never throws
59
+ * on user-recoverable failures (per-vendor errors are captured in
60
+ * each result entry).
61
+ *
62
+ * @param {CohortHookOptions} options
63
+ * @returns {{ hooks: import("../lib/agent-hook-merge.mjs").AgentHookInstallResult[] }}
64
+ */
65
+ export function installCohortHooks({ noSessionSync, vendors, p }) {
66
+ if (noSessionSync) {
67
+ // Match the Claude path's --no-session-sync semantics: silent
68
+ // skip. The Claude path already prints the warning, so we don't
69
+ // double-warn here. If somehow the user passes --no-session-sync
70
+ // AND no Claude target (so the Claude path doesn't print), the
71
+ // user got exactly what they asked for — explicit opt-out.
72
+ return { hooks: [] };
73
+ }
74
+
75
+ // Filter vendors to those with an agentHook spec. The dispatcher
76
+ // does this filtering internally too (silently skipping unknown
77
+ // entries), but doing it here lets us avoid the noise of "Skipped
78
+ // claudeCode (no agentHook)" lines for every vendor in the cohort
79
+ // list that isn't ours to install. Claude Code, Windsurf, and
80
+ // Cline all hit this filter; only the four cohort vendors with
81
+ // `agentHook != null` reach the dispatcher.
82
+ const eligible = vendors.filter((v) => {
83
+ const entry = getAgentByKey(v);
84
+ return entry && entry.agentHook !== null;
85
+ });
86
+
87
+ if (eligible.length === 0) {
88
+ return { hooks: [] };
89
+ }
90
+
91
+ const results = installAgentHooksForVendors({ vendors: eligible });
92
+
93
+ // Surface each result on the printer so the user sees what was
94
+ // written / refreshed / failed. Mirrors `installSessionSyncHook`'s
95
+ // per-action printing for Claude.
96
+ for (const r of results) {
97
+ if (r.action === "installed") {
98
+ p.success(`Cohort SessionStart hook installed for ${r.displayName} (${r.path})`);
99
+ } else if (r.action === "updated") {
100
+ p.success(`Cohort SessionStart hook updated for ${r.displayName} (${r.path})`);
101
+ } else if (r.action === "unchanged") {
102
+ p.success(`Cohort SessionStart hook already installed for ${r.displayName} (${r.path})`);
103
+ } else if (r.action === "failed") {
104
+ p.warning(
105
+ `Cohort SessionStart hook for ${r.displayName} failed: ${r.reason}. ` +
106
+ `Run \`skillrepo init\` again after fixing the issue.`,
107
+ );
108
+ }
109
+ }
110
+
111
+ // VS Code + Copilot's hook system is currently labelled Preview by
112
+ // GitHub (#1244). Surface that caveat once if Copilot was among the
113
+ // installed vendors so users know the hook schema may shift before
114
+ // GA. Skip the warning if Copilot's install actually failed — we
115
+ // already printed the failure line above and a Preview note would
116
+ // muddle the actionable signal.
117
+ const copilotResult = results.find((r) => r.vendorKey === "copilot");
118
+ if (copilotResult && copilotResult.action !== "failed") {
119
+ p.warning(
120
+ "Copilot's SessionStart hook system is currently labelled Preview by GitHub. " +
121
+ "The schema may shift before GA; re-run `skillrepo init` after upgrading the CLI " +
122
+ "if Copilot's hook config format changes.",
123
+ );
124
+ }
125
+
126
+ return { hooks: results };
127
+ }
@@ -81,6 +81,7 @@ import {
81
81
  import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
82
82
  import { mergeGitignore } from "../lib/mergers/gitignore.mjs";
83
83
  import { installSessionSyncHook } from "./init-session-sync.mjs";
84
+ import { installCohortHooks } from "./init-cohort-hooks.mjs";
84
85
  import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
85
86
  import {
86
87
  claudeSkillsProjectRoot,
@@ -466,14 +467,32 @@ export async function runInit(argv, io = {}, deps = {}) {
466
467
  }
467
468
  p.blank();
468
469
 
469
- // ── Step 6: Session sync (#884, v3.1.2 auto-install #894) ─────
470
+ // ── Step 6: Session sync (#884 Claude path + #1240 cohort path)
470
471
  //
471
- // The full decision tree (six branches) lives in
472
- // `init-session-sync.mjs`. This step is inserted between MCP
473
- // merge (step 5) and the first sync (step 7) — order matters
474
- // because the hook's `skillrepo update` calls expect the config
475
- // to already be written (step 3).
472
+ // Two sibling installers run here:
473
+ //
474
+ // - Claude Code's SessionStart hook (#884) — `installSessionSyncHook`
475
+ // in `init-session-sync.mjs`. Has its own six-branch decision
476
+ // tree because Claude Code's hook needs an absolute-path command
477
+ // (Claude doesn't load PATH at hook time), which under `npx
478
+ // skillrepo init` requires offering to install skillrepo
479
+ // globally first.
480
+ // - Cohort SessionStart hooks (#1240) — `installCohortHooks` in
481
+ // `init-cohort-hooks.mjs`. Writes hooks for every selected vendor
482
+ // whose agent-registry entry has a non-null `agentHook` spec
483
+ // (Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). Uses
484
+ // `npx --yes skillrepo update --silent` as the universal hook
485
+ // command — no global install needed.
486
+ //
487
+ // The two flows are deliberately separate. Folding them into a single
488
+ // function would create irreducible branching by Phase 4 (#1241-1244)
489
+ // because Claude's binaryPath resolution has no analogue in the
490
+ // cohort path.
491
+ //
492
+ // `--no-session-sync` skips BOTH paths (semantics widened in #1240
493
+ // from "skip Claude" to "skip all auto-refresh hooks").
476
494
  p.step(6, 7, "Session sync");
495
+
477
496
  // Install the Claude Code SessionStart hook only when Claude Code
478
497
  // is actually a target. Pre-#1249 this condition also included
479
498
  // `Boolean(flags.global)` because bare `--global` historically
@@ -495,6 +514,16 @@ export async function runInit(argv, io = {}, deps = {}) {
495
514
  const sessionSyncAction = sessionSync.action;
496
515
  const sessionSyncPath = sessionSync.path;
497
516
  const globalInstallActive = sessionSync.globalInstallActive;
517
+
518
+ // Cohort SessionStart hooks for every non-Claude selected vendor
519
+ // with an `agentHook` spec. Skipped under `--no-session-sync` AND
520
+ // when no eligible vendors are selected (e.g. `--agent claude` only,
521
+ // or `--agent none`). Per-vendor failures do not abort init —
522
+ // each result entry carries its own `action: "failed"` + `reason`.
523
+ const cohortHooksResult =
524
+ Array.isArray(vendors) && vendors.length > 0
525
+ ? installCohortHooks({ noSessionSync, vendors, p })
526
+ : { hooks: [] };
498
527
  p.blank();
499
528
 
500
529
  // ── Step 7: First sync ───────────────────────────────────────
@@ -637,9 +666,19 @@ export async function runInit(argv, io = {}, deps = {}) {
637
666
  // Session-sync block. `action` is one of the
638
667
  // `SessionSyncAction` enum values defined in
639
668
  // `./session-sync-actions.mjs` (single source of truth).
669
+ // `cohortHooks` (added in #1240) reports per-vendor cohort
670
+ // SessionStart hook outcomes — empty array when no cohort
671
+ // vendor was selected or when --no-session-sync was passed.
640
672
  sessionSync: {
641
673
  action: sessionSyncAction,
642
674
  path: sessionSyncPath,
675
+ cohortHooks: cohortHooksResult.hooks.map((h) => ({
676
+ vendorKey: h.vendorKey,
677
+ displayName: h.displayName,
678
+ path: h.path,
679
+ action: h.action,
680
+ ...(h.reason ? { reason: h.reason } : {}),
681
+ })),
643
682
  },
644
683
  // Sync block always shows the counts (zeroed on failure)
645
684
  // and adds `failureReason` when the first sync blew up —
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `skillrepo push <path>` (#1455).
3
+ *
4
+ * Smart upsert: walks a local skill directory and uploads it via
5
+ * `POST /api/v1/library` (#1452 multipart endpoint). The server detects
6
+ * existence by SKILL.md frontmatter name and either creates a new
7
+ * private skill (first push) or releases a new version (subsequent push
8
+ * with changed content). Identical content is a server-side no-op.
9
+ *
10
+ * **No write-back.** The files already live at `<path>` on the user's
11
+ * disk. The CLI uploads them and prints success — it does not write
12
+ * anything back. `skillrepo update` remains the canonical command for
13
+ * disk sync from server → local.
14
+ *
15
+ * Flags: --idempotency-key / --json / --key / --url
16
+ * Positional: <path>
17
+ */
18
+
19
+ import { promises as fs } from "node:fs";
20
+ import path from "node:path";
21
+ import matter from "gray-matter";
22
+
23
+ import { pushSkill } from "../lib/http.mjs";
24
+ import { walkSkillFiles } from "../lib/skill-walk.mjs";
25
+ import { resolveFlags } from "../lib/cli-config.mjs";
26
+ import { validationError } from "../lib/errors.mjs";
27
+
28
+ /**
29
+ * Run `push`. Throws CliError on failure.
30
+ *
31
+ * @param {string[]} argv
32
+ * @param {object} [io]
33
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
34
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
35
+ */
36
+ export async function runPush(argv, io = {}) {
37
+ const stdout = io.stdout ?? process.stdout;
38
+ let skillPath = null;
39
+
40
+ // `resolveFlags` only knows the shared flags (`--global`, `--agent`,
41
+ // `--json`, `--key`, `--url`, `--verbose`). The per-command value flag
42
+ // (`--idempotency-key <val>`) is consumed in `acceptPositional` by
43
+ // returning `2` to claim both the flag name and its value.
44
+ let idempotencyKey = null;
45
+ const flags = resolveFlags(argv, {
46
+ acceptPositional(arg, i, allArgv) {
47
+ // Value flags: claim flag + value (2 args).
48
+ if (arg === "--idempotency-key") {
49
+ if (allArgv[i + 1] === undefined) {
50
+ throw validationError("Missing value for --idempotency-key.", {
51
+ hint: "Pass a key, e.g., --idempotency-key my-uuid-here.",
52
+ });
53
+ }
54
+ idempotencyKey = allArgv[i + 1];
55
+ return 2;
56
+ }
57
+ // Otherwise it must be the (sole) positional skill-path argument.
58
+ if (skillPath !== null) {
59
+ throw validationError(`Unexpected extra argument: ${arg}`, {
60
+ hint: "Pass exactly one local directory path.",
61
+ });
62
+ }
63
+ skillPath = arg;
64
+ return 1;
65
+ },
66
+ });
67
+
68
+ if (!skillPath) {
69
+ throw validationError("Missing skill directory path.", {
70
+ hint: "Usage: skillrepo push <path-to-skill>",
71
+ });
72
+ }
73
+
74
+ // ── Resolve the skill directory ────────────────────────────────────
75
+ const absDir = path.resolve(process.cwd(), skillPath);
76
+ let stat;
77
+ try {
78
+ stat = await fs.stat(absDir);
79
+ } catch (err) {
80
+ throw validationError(
81
+ `Path not found: ${skillPath}`,
82
+ { hint: `Resolved to ${absDir}. Pass a directory containing a SKILL.md.`, cause: err },
83
+ );
84
+ }
85
+ if (!stat.isDirectory()) {
86
+ throw validationError(`Not a directory: ${skillPath}`, {
87
+ hint: "Pass a directory containing a SKILL.md, not a single file.",
88
+ });
89
+ }
90
+
91
+ // ── Read + parse SKILL.md (local validation only) ──────────────────
92
+ // The walker below picks SKILL.md up as a regular file and sends it
93
+ // through the multipart `files[]` array. We still read it here for an
94
+ // early failure check: without this the user would upload every
95
+ // supporting file before the server's frontmatter-parser rejected the
96
+ // push.
97
+ const skillMdPath = path.join(absDir, "SKILL.md");
98
+ let skillMdLocal;
99
+ try {
100
+ skillMdLocal = await fs.readFile(skillMdPath, "utf-8");
101
+ } catch (err) {
102
+ throw validationError(`No SKILL.md at ${skillPath}/SKILL.md.`, {
103
+ hint:
104
+ "Every skill must have a SKILL.md at its root with YAML " +
105
+ "frontmatter including `name` and `description` fields.",
106
+ cause: err,
107
+ });
108
+ }
109
+
110
+ let frontmatter;
111
+ try {
112
+ const parsed = matter(skillMdLocal);
113
+ frontmatter = parsed.data;
114
+ } catch (err) {
115
+ throw validationError(
116
+ `SKILL.md frontmatter could not be parsed.`,
117
+ {
118
+ hint:
119
+ "Ensure the file starts with `---`, contains valid YAML, and " +
120
+ "ends the frontmatter block with `---` on its own line.",
121
+ cause: err,
122
+ },
123
+ );
124
+ }
125
+
126
+ if (!frontmatter?.name || typeof frontmatter.name !== "string") {
127
+ throw validationError("SKILL.md is missing the required `name` field.", {
128
+ hint: "Add `name: my-skill-name` to the YAML frontmatter.",
129
+ });
130
+ }
131
+
132
+ // ── Walk the skill folder ──────────────────────────────────────────
133
+ // The walker returns every file (including the root `SKILL.md`) per
134
+ // the agentskills.io spec. They all go up as `files[]` parts.
135
+ const walked = await walkSkillFiles(absDir);
136
+
137
+ const files = await Promise.all(
138
+ walked.map(async (f) => ({
139
+ relativePath: f.relativePath,
140
+ content: await fs.readFile(f.absolutePath),
141
+ })),
142
+ );
143
+
144
+ // ── POST to /api/v1/library ────────────────────────────────────────
145
+ const result = await pushSkill(flags.serverUrl, flags.apiKey, {
146
+ files,
147
+ idempotencyKey: idempotencyKey ?? undefined,
148
+ });
149
+
150
+ // ── Report ─────────────────────────────────────────────────────────
151
+ const totalUploaded = files.length;
152
+ if (flags.json) {
153
+ stdout.write(
154
+ JSON.stringify(
155
+ {
156
+ action: result.action,
157
+ bump: result.bump,
158
+ owner: result.skill?.owner ?? null,
159
+ name: result.skill?.name ?? frontmatter.name,
160
+ version: result.skill?.version ?? null,
161
+ filesUploaded: totalUploaded,
162
+ },
163
+ null,
164
+ 2,
165
+ ) + "\n",
166
+ );
167
+ return;
168
+ }
169
+
170
+ const ident = `@${result.skill?.owner ?? "?"}/${result.skill?.name ?? frontmatter.name}`;
171
+ const fileCount = `${totalUploaded} file${totalUploaded === 1 ? "" : "s"}`;
172
+
173
+ if (result.action === "created") {
174
+ stdout.write(
175
+ `\n ✓ Created ${ident} v${result.skill?.version ?? "1.0"} (${fileCount})\n\n`,
176
+ );
177
+ } else if (result.action === "updated") {
178
+ stdout.write(
179
+ `\n ✓ Released ${ident} v${result.skill?.version} (${result.bump} bump, ${fileCount})\n\n`,
180
+ );
181
+ } else {
182
+ // unchanged
183
+ stdout.write(
184
+ `\n ✓ No changes — ${ident} is already at v${result.skill?.version}\n\n`,
185
+ );
186
+ }
187
+ }
@@ -58,6 +58,7 @@ import { removeCursorMcp } from "../lib/removers/cursor-mcp.mjs";
58
58
  import { removeVscodeMcp } from "../lib/removers/vscode-mcp.mjs";
59
59
  import { removeWindsurfMcp } from "../lib/removers/windsurf-mcp.mjs";
60
60
  import { removeSettingsSessionHook } from "../lib/removers/settings.mjs";
61
+ import { removeAgentHookArtifact } from "../lib/removers/agent-hooks.mjs";
61
62
  import { confirm } from "../lib/prompt.mjs";
62
63
  import { resolveFlags } from "../lib/cli-config.mjs";
63
64
  import { diskError } from "../lib/errors.mjs";
@@ -398,6 +399,14 @@ function runForDescriptor(descriptor, { dryRun }) {
398
399
  if (descriptor.kind === "directory") {
399
400
  return removeDirectoryArtifact(descriptor, { dryRun });
400
401
  }
402
+ // Cohort SessionStart-hook descriptors (#1240) dispatch by `kind`
403
+ // rather than by id: every `kind: "agent-hook"` descriptor goes to
404
+ // the same batch remover, which uses the descriptor's `vendorKey`
405
+ // to route to the per-shape merger. Adding a new cohort vendor =
406
+ // one registry entry + one descriptor; this dispatch never changes.
407
+ if (descriptor.kind === "agent-hook") {
408
+ return removeAgentHookArtifact(descriptor, { dryRun });
409
+ }
401
410
  const fn = FILE_REMOVERS[descriptor.id];
402
411
  if (!fn) {
403
412
  // Only reachable for descriptors that share a remover with a
@@ -418,7 +427,9 @@ function renderPreviewLine(descriptor, result) {
418
427
  ? " [dir] "
419
428
  : descriptor.kind === "line" || descriptor.kind === "section"
420
429
  ? " [lines] "
421
- : " [entry] ";
430
+ : descriptor.kind === "agent-hook"
431
+ ? " [hook] "
432
+ : " [entry] ";
422
433
  const detail = result.error
423
434
  ? `→ ${result.error}`
424
435
  : result.detail