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 +49 -2
- package/package.json +1 -1
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init.mjs +45 -6
- package/src/commands/uninstall.mjs +12 -1
- package/src/commands/update.mjs +97 -16
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +186 -2
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/test/commands/init.test.mjs +281 -0
- package/src/test/commands/update.test.mjs +135 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
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": {
|
|
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
|
@@ -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
|
+
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
|
470
|
+
// ── Step 6: Session sync (#884 Claude path + #1240 cohort path) ─
|
|
470
471
|
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
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
|
-
:
|
|
430
|
+
: descriptor.kind === "agent-hook"
|
|
431
|
+
? " [hook] "
|
|
432
|
+
: " [entry] ";
|
|
422
433
|
const detail = result.error
|
|
423
434
|
? `→ ${result.error}`
|
|
424
435
|
: result.detail
|
package/src/commands/update.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
72
|
-
//
|
|
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
|
|
82
|
-
//
|
|
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:
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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
|