skillrepo 4.0.0 → 4.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.0.0",
3
+ "version": "4.1.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": {
@@ -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 —
@@ -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
@@ -21,6 +21,22 @@
21
21
  * command behaves as before (exit non-zero on
22
22
  * network / auth / disk failures).
23
23
  *
24
+ * v4.1.0 silent mode (#1240):
25
+ * --silent Suppress stdout: write `{}` on success, propagate
26
+ * failures (non-zero exit) with the error on stderr.
27
+ * Used by the per-agent SessionStart hooks (Cursor,
28
+ * Gemini CLI, Codex CLI, VS Code + Copilot) the
29
+ * cohort installer writes via `npx --yes skillrepo
30
+ * update --silent`. Gemini CLI specifically requires
31
+ * hook stdout to be valid JSON; the empty `{}`
32
+ * satisfies that. Distinct from `--session-hook` —
33
+ * `--silent` does NOT suppress error exit codes,
34
+ * because none of the cohort agents block session
35
+ * start on non-zero hook exits the way Claude Code
36
+ * does. If primary-source evidence later shows a
37
+ * cohort vendor DOES block, give that vendor its
38
+ * own contract flag rather than widening this one.
39
+ *
24
40
  * Exit codes are inherited from sync.mjs / http.mjs error types,
25
41
  * EXCEPT under `--session-hook` — see the flag's contract below.
26
42
  */
@@ -62,14 +78,12 @@ import {
62
78
  */
63
79
  export async function runUpdate(argv, io = {}) {
64
80
  const stdout = io.stdout ?? process.stdout;
65
- // `stderr` is not used directly — the session-hook path pipes to
66
- // BLACK_HOLE_STREAM and the normal path forwards `io` to runSync
67
- // which reads `io.stderr` itself.
81
+ const stderr = io.stderr ?? process.stderr;
68
82
 
69
- // ── Pre-pass: detect --session-hook BEFORE resolveFlags runs ─────
83
+ // ── Pre-pass: detect --session-hook / --silent BEFORE resolveFlags runs
70
84
  //
71
- // resolveFlags can throw in three categories we must catch under
72
- // session-hook mode:
85
+ // resolveFlags can throw in three categories the wrapping mode must
86
+ // catch:
73
87
  // - `authError` when no access key is configured (e.g., session
74
88
  // fires before `skillrepo init` has run — a real first-run
75
89
  // scenario, not synthetic)
@@ -78,15 +92,27 @@ export async function runUpdate(argv, io = {}) {
78
92
  //
79
93
  // All three happen INSIDE resolveFlags, before our try/catch block
80
94
  // could see them if we called it after. The only robust answer is
81
- // to detect --session-hook without invoking resolveFlags, then
82
- // wrap resolveFlags + runSync together in the error handler.
95
+ // to detect the mode flags without invoking resolveFlags, then wrap
96
+ // resolveFlags + runSync together in the error handler.
83
97
  //
84
- // A simple argv scan is safe here: `--session-hook` has no value
85
- // argument, so a plain `.includes` match can't misinterpret
86
- // positional args. This DOES NOT accept variations like
87
- // `--session-hook=true` or `-SH` — single canonical form only,
88
- // matching what the installer writes.
98
+ // A simple argv scan is safe here: neither flag takes a value, so a
99
+ // plain `.includes` match can't misinterpret positional args. This
100
+ // DOES NOT accept variations like `--session-hook=true` or `-S` —
101
+ // single canonical form only, matching what the installers write.
89
102
  const sessionHook = argv.includes("--session-hook");
103
+ const silent = argv.includes("--silent");
104
+
105
+ // Precedence: when both flags appear in argv, `--session-hook`
106
+ // wins because the order of the branches below dispatches it
107
+ // first. The session-hook path's `acceptPositional` accepts both
108
+ // flags so it doesn't reject `--silent` as unknown. The silent
109
+ // path only accepts `--silent` (different exit-code contract).
110
+ // Don't reorder these branches without coordinating with the two
111
+ // hook-installer paths in `commands/init-cohort-hooks.mjs` and
112
+ // `mergers/session-hook.mjs`. Practical exposure: zero — neither
113
+ // installer writes a hook command containing both flags. The
114
+ // precedence is defensive against a future code path or a manual
115
+ // user edit, not an active scenario.
90
116
 
91
117
  if (sessionHook) {
92
118
  // Session-hook mode: wrap EVERY error path in try/catch so a
@@ -94,10 +120,13 @@ export async function runUpdate(argv, io = {}) {
94
120
  try {
95
121
  const flags = resolveFlags(argv, {
96
122
  acceptPositional(arg) {
97
- // resolveFlags sees --session-hook as unknown unless we
98
- // consume it here. Kept for the non-error path the
99
- // real "catch errors" logic is the outer try.
123
+ // resolveFlags sees these as unknown unless we consume them
124
+ // here. Both flags are accepted because a future caller may
125
+ // combine them (defense installers should pick one); the
126
+ // outer mode dispatch above already chose `session-hook` if
127
+ // present, so accepting `--silent` here is a no-op tolerance.
100
128
  if (arg === "--session-hook") return 1;
129
+ if (arg === "--silent") return 1;
101
130
  return false;
102
131
  },
103
132
  });
@@ -148,6 +177,58 @@ export async function runUpdate(argv, io = {}) {
148
177
  return;
149
178
  }
150
179
 
180
+ // ── Silent mode (#1240): cohort SessionStart hook contract ──────
181
+ //
182
+ // Used by Cursor / Gemini CLI / Codex CLI / VS Code + Copilot. Their
183
+ // SessionStart hook config invokes `npx --yes skillrepo update
184
+ // --silent`. Contract:
185
+ //
186
+ // - stdout produces ONE valid-JSON line on success: `{}`. Gemini
187
+ // CLI specifically requires hook stdout to be JSON; the empty
188
+ // object is the minimal valid value that injects no model
189
+ // context. Other vendors tolerate it.
190
+ // - On failure, stdout writes nothing extra; the typed error
191
+ // propagates through the dispatcher's catch which writes to
192
+ // stderr and exits with the appropriate code. We do NOT write
193
+ // `{}` on failure — partial JSON output alongside an error
194
+ // message on stderr would mislead a hook runner that treats
195
+ // stdout as model context.
196
+ // - All sync progress lines (the "failed to persist last-sync
197
+ // state" warning from sync.mjs, etc.) are routed to a black-hole
198
+ // stdout so they cannot leak into the JSON expectation.
199
+ //
200
+ // Distinct from `--session-hook`. That mode is Claude-Code-specific
201
+ // and contracts "EXIT 0 ON ALL ERRORS" because Claude Code's hook
202
+ // runner blocks session start on non-zero exits. The cohort vendors
203
+ // handled here have no documented session-blocking behavior, so a
204
+ // failure should surface as a real exit code — the user can then
205
+ // investigate via `skillrepo update` directly.
206
+ if (silent) {
207
+ const flags = resolveFlags(argv, {
208
+ acceptPositional(arg) {
209
+ if (arg === "--silent") return 1;
210
+ return false;
211
+ },
212
+ });
213
+ const vendors = effectiveVendors(flags);
214
+ requireVendorTargets(vendors, "update");
215
+
216
+ await runSync({
217
+ serverUrl: flags.serverUrl,
218
+ apiKey: flags.apiKey,
219
+ vendors,
220
+ global: flags.global,
221
+ // sync.mjs surfaces non-fatal warnings (e.g. failed to persist
222
+ // last-sync state) via stderr; preserve that channel so a real
223
+ // operator running `update --silent` from a terminal can still
224
+ // see them. Stdout is the contract-bearing stream and stays
225
+ // black-hole until we emit `{}` ourselves.
226
+ io: { stdout: BLACK_HOLE_STREAM, stderr },
227
+ });
228
+ stdout.write("{}\n");
229
+ return;
230
+ }
231
+
151
232
  // ── Normal mode: original behavior ───────────────────────────────
152
233
  // Forward `io` to runSync so the non-fatal "failed to persist
153
234
  // last-sync state" warning lands on the injected stderr stream