skillrepo 3.1.0 → 3.1.2

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.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/src/commands/init-session-sync.mjs +307 -0
  4. package/src/commands/init.mjs +111 -101
  5. package/src/commands/session-sync-actions.mjs +92 -0
  6. package/src/lib/artifact-registry.mjs +43 -3
  7. package/src/lib/binary-locator.mjs +99 -0
  8. package/src/lib/cli-config.mjs +16 -3
  9. package/src/lib/cli-version.mjs +56 -0
  10. package/src/lib/config.mjs +6 -3
  11. package/src/lib/file-write.mjs +8 -3
  12. package/src/lib/fs-utils.mjs +9 -10
  13. package/src/lib/global-install.mjs +387 -0
  14. package/src/lib/mcp-merge.mjs +16 -5
  15. package/src/lib/mergers/session-hook.mjs +125 -33
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/sync.mjs +26 -0
  18. package/src/lib/transient-runners.mjs +204 -0
  19. package/src/test/commands/add.test.mjs +10 -4
  20. package/src/test/commands/get.test.mjs +10 -4
  21. package/src/test/commands/init.test.mjs +889 -15
  22. package/src/test/commands/list.test.mjs +10 -4
  23. package/src/test/commands/remove.test.mjs +10 -4
  24. package/src/test/commands/search.test.mjs +10 -4
  25. package/src/test/commands/session-sync-actions.test.mjs +74 -0
  26. package/src/test/commands/session-sync.test.mjs +25 -23
  27. package/src/test/commands/uninstall.test.mjs +20 -14
  28. package/src/test/commands/update.test.mjs +10 -4
  29. package/src/test/helpers/mock-spawn.mjs +121 -0
  30. package/src/test/helpers/sandbox-home.mjs +161 -0
  31. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  32. package/src/test/integration/file-write.integration.test.mjs +10 -4
  33. package/src/test/lib/cli-config.test.mjs +182 -4
  34. package/src/test/lib/cli-version.test.mjs +47 -0
  35. package/src/test/lib/config.test.mjs +10 -4
  36. package/src/test/lib/file-write.test.mjs +24 -10
  37. package/src/test/lib/global-install.test.mjs +424 -0
  38. package/src/test/lib/mcp-merge.test.mjs +13 -7
  39. package/src/test/lib/paths.test.mjs +10 -4
  40. package/src/test/lib/platform.test.mjs +135 -0
  41. package/src/test/lib/sync.test.mjs +20 -4
  42. package/src/test/lib/transient-runners.test.mjs +270 -0
  43. package/src/test/mergers/session-hook.test.mjs +722 -22
  44. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  45. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
package/README.md CHANGED
@@ -53,7 +53,7 @@ below), and runs the first library sync.
53
53
  |------|-------------|
54
54
  | `--key, -k <key>` | Access key. Falls back to `SKILLREPO_ACCESS_KEY` env var, then interactive prompt. |
55
55
  | `--url, -u <url>` | Server URL. Defaults to `https://skillrepo.dev`. Use for self-hosted. |
56
- | `--yes, -y` | Non-interactive. Skip all confirmation prompts. Required for CI. Installs the session-sync hook by default — pass `--no-session-sync` to opt out. |
56
+ | `--yes, -y` | Non-interactive. Skip all confirmation prompts. Required for CI. Installs the session-sync hook by default — pass `--no-session-sync` to opt out. Under `npx`, this also auto-runs `npm install -g skillrepo@<this-version>` if no global install is present (so the session-sync hook can use the resulting absolute path). |
57
57
  | `--force` | Re-prompt for a new key even if `~/.claude/skillrepo/config.json` is valid. |
58
58
  | `--ide <list>` | Comma-separated vendor override. One or more of `claude`, `cursor`, `windsurf`, `vscode`, or `all`. |
59
59
  | `--global` | Write skills to `~/.claude/skills/` (personal) instead of `.claude/skills/` (project). |
@@ -142,7 +142,11 @@ Installs (or removes) a Claude Code [SessionStart hook](https://docs.claude.com/
142
142
 
143
143
  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.
144
144
 
145
- **The hook cannot block your session.** The command it runs is `skillrepo update --session-hook 2>&1 || true`. That flag makes `update` exit 0 on every failure network outage, revoked key, disk error, anything and print a single-line failure message to your session. The `|| true` shell backstop catches anything that escapes. Session starts are never blocked by sync failures.
145
+ **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.
146
+
147
+ `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.
148
+
149
+ **The hook cannot block your session.** The command it runs is `<path-to-skillrepo> update --session-hook 2>&1 [|| true]`. The `--session-hook` flag makes `update` exit 0 on every failure — network outage, revoked key, disk error, anything — and print a single-line failure message to your session. On POSIX systems the `|| true` shell backstop is appended as belt-and-suspenders; on Windows it's omitted because cmd.exe doesn't know the `true` builtin (the `--session-hook` flag's exit-0 contract is the primary defense regardless of platform). Session starts are never blocked by sync failures.
146
150
 
147
151
  **On 304 (nothing changed) the hook is silent.** You only see output when your library actually syncs or a failure happens. No "Syncing…" noise on every session.
148
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
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,307 @@
1
+ /**
2
+ * `skillrepo init` step 6 — Claude Code SessionStart hook installation.
3
+ *
4
+ * Extracted from `init.mjs` for readability (#894 / v3.1.2). The
5
+ * v3.1.2 auto-install-global feature added five branches to what
6
+ * was a two-branch step in v3.1.0; nesting that inside init.mjs
7
+ * crossed the readability threshold. This module owns the entire
8
+ * decision tree and returns a flat result the caller integrates
9
+ * into the init summary + JSON output.
10
+ *
11
+ * ## Decision tree
12
+ *
13
+ * noSessionSync → "opted-out" (no further action)
14
+ * !claudeTargeted → "not-applicable" (Claude-specific
15
+ * hook would never fire)
16
+ * non-npx install → resolve via PATH, install hook
17
+ * npx with EXISTING global → use the global directly
18
+ * npx without global, --yes → auto-install global, install hook
19
+ * npx without global, prompt → ask first, then auto-install or
20
+ * decline
21
+ *
22
+ * The auto-install branch runs `npm install -g skillrepo@<version>`
23
+ * itself so the user doesn't have to. v3.1.1 skipped the install
24
+ * with a "do it yourself" warning AFTER prompting for consent —
25
+ * the prompt-then-fail UX bug v3.1.2 fixes.
26
+ *
27
+ * ## Return shape
28
+ *
29
+ * {
30
+ * action: SessionSyncAction, // see ./session-sync-actions.mjs
31
+ * path: string | null, // settings.local.json display path
32
+ * globalInstallActive: boolean, // true when a stable global
33
+ * // skillrepo binary is now on
34
+ * // PATH (used by Next-Steps to
35
+ * // suppress the now-stale
36
+ * // "install globally" tip)
37
+ * }
38
+ *
39
+ * ## Failure semantics
40
+ *
41
+ * Every recoverable failure (auto-install error, hook write error,
42
+ * version-read error) sets `action` to a descriptive enum value and
43
+ * returns. The caller (init.mjs) does NOT abort init — the rest of
44
+ * init has already succeeded and the user's library is on disk.
45
+ */
46
+
47
+ import { mergeSessionHook } from "../lib/mergers/session-hook.mjs";
48
+ import {
49
+ installSkillrepoGlobally,
50
+ resolveGlobalBinary,
51
+ } from "../lib/global-install.mjs";
52
+ import { getCliVersion } from "../lib/cli-version.mjs";
53
+ import {
54
+ detectTransientRunner,
55
+ isTransientRunnerInvocation,
56
+ globalInstallCommandFor,
57
+ } from "../lib/transient-runners.mjs";
58
+ import { confirm as realConfirm } from "../lib/prompt.mjs";
59
+ import { SessionSyncAction, isHookActive } from "./session-sync-actions.mjs";
60
+
61
+ /**
62
+ * @typedef {Object} SessionSyncResult
63
+ * @property {string} action - One of the `SessionSyncAction` enum values.
64
+ * @property {string | null} path - Display path of the settings file
65
+ * the hook was written to (or `null` if no write happened).
66
+ * @property {boolean} globalInstallActive - True if a stable global
67
+ * skillrepo binary is on PATH after this step. Used by the
68
+ * Next-Steps Tip block to decide whether to suggest
69
+ * `npm install -g`.
70
+ */
71
+
72
+ /**
73
+ * @typedef {Object} SessionSyncOptions
74
+ * @property {boolean} noSessionSync - True if `--no-session-sync`.
75
+ * @property {boolean} claudeTargeted - True if Claude Code is in the
76
+ * vendor target list (via `--ide claude` or `--global` or
77
+ * auto-detection).
78
+ * @property {boolean} yes - True if `--yes`.
79
+ * @property {boolean} json - True if `--json`. Affects spawn output
80
+ * mode (silent vs inherit) so npm progress doesn't pollute
81
+ * the JSON stdout.
82
+ * @property {boolean} global - True if `--global` (writes to
83
+ * `~/.claude/settings.local.json`).
84
+ * @property {object} p - Printer (from init.mjs makePrinter).
85
+ * @property {object} [deps] - Test-only injection.
86
+ * @property {Function} [deps.spawn] - For installSkillrepoGlobally.
87
+ * @property {Function} [deps.getCliVersion] - For testing the version-
88
+ * read failure recovery path (ESM exports can't be
89
+ * monkey-patched).
90
+ * @property {Function} [deps.confirmFn] - For testing the interactive
91
+ * prompt path. Default: real `confirm` from prompt.mjs.
92
+ */
93
+
94
+ /**
95
+ * Run step 6. Always returns a result; never throws on user-recoverable
96
+ * failures. (`mergeSessionHook` may throw a disk error on a corrupt
97
+ * settings file — that's caught and surfaced as `action: "failed"`.)
98
+ *
99
+ * @param {SessionSyncOptions} options
100
+ * @returns {Promise<SessionSyncResult>}
101
+ */
102
+ export async function installSessionSyncHook({
103
+ noSessionSync,
104
+ claudeTargeted,
105
+ yes,
106
+ json,
107
+ global,
108
+ p,
109
+ deps = {},
110
+ }) {
111
+ if (noSessionSync) {
112
+ p.warning("Session sync skipped (--no-session-sync).");
113
+ return result(SessionSyncAction.OptedOut);
114
+ }
115
+
116
+ if (!claudeTargeted) {
117
+ p.warning(
118
+ "Session sync skipped: the SessionStart hook is Claude Code-specific " +
119
+ "and no Claude Code target was configured.",
120
+ );
121
+ return result(SessionSyncAction.NotApplicable);
122
+ }
123
+
124
+ const npxMode = isTransientRunnerInvocation();
125
+ if (!npxMode) {
126
+ return await runNonNpxBranch({ yes, global, p, deps });
127
+ }
128
+
129
+ // Transient-runner mode (npx/pnpx/yarn dlx/bunx). Decide between
130
+ // using a pre-existing global and
131
+ // offering to install one.
132
+ const existingGlobalBinary = resolveGlobalBinary();
133
+ if (existingGlobalBinary) {
134
+ return runUseExistingGlobalBranch({
135
+ binaryPath: existingGlobalBinary,
136
+ global,
137
+ p,
138
+ });
139
+ }
140
+ return await runAutoInstallBranch({ yes, json, global, p, deps });
141
+ }
142
+
143
+ // ── Branch implementations ───────────────────────────────────────────
144
+
145
+ /**
146
+ * Non-npx case: stable install with skillrepo on PATH (or PATH
147
+ * misconfigured — handled gracefully). Existing v3.1.0/v3.1.1 path,
148
+ * preserved unchanged.
149
+ */
150
+ async function runNonNpxBranch({ yes, global, p, deps }) {
151
+ const confirmFn = deps.confirmFn ?? realConfirm;
152
+ const proceed =
153
+ yes ||
154
+ (await confirmFn(
155
+ "Install Claude Code SessionStart hook so your library auto-syncs on every session start?",
156
+ true,
157
+ ));
158
+ if (!proceed) {
159
+ p.warning(
160
+ "Session sync skipped. Run `skillrepo session-sync enable` to install it later.",
161
+ );
162
+ return result(SessionSyncAction.Declined);
163
+ }
164
+ return tryMergeAndPrint({ global, p });
165
+ }
166
+
167
+ /**
168
+ * Pre-existing global on PATH (likely user installed manually before
169
+ * running `npx skillrepo init`). No install offer; use the existing
170
+ * binary directly via the binaryPath bypass.
171
+ */
172
+ function runUseExistingGlobalBranch({ binaryPath, global, p }) {
173
+ p.success(
174
+ `Found global skillrepo at ${binaryPath} — using it for session sync.`,
175
+ );
176
+ const r = tryMergeAndPrint({ global, p, binaryPath });
177
+ // Pre-existing global IS active regardless of hook write outcome.
178
+ return { ...r, globalInstallActive: true };
179
+ }
180
+
181
+ /**
182
+ * Auto-install branch (the v3.1.2 fix): under npx with no global,
183
+ * install ourselves globally then install the hook with the resulting
184
+ * absolute path.
185
+ */
186
+ async function runAutoInstallBranch({ yes, json, global, p, deps }) {
187
+ const confirmFn = deps.confirmFn ?? realConfirm;
188
+ const proceed =
189
+ yes ||
190
+ (await confirmFn(
191
+ "SkillRepo needs a global install to enable session sync. " +
192
+ "Install `skillrepo` globally now?",
193
+ true,
194
+ ));
195
+ if (!proceed) {
196
+ p.warning("Session sync skipped (declined).");
197
+ printManualSteps(p, detectTransientRunner());
198
+ return result(SessionSyncAction.Declined);
199
+ }
200
+
201
+ // `getCliVersion` throws on a corrupt CLI tarball. Catch so init
202
+ // doesn't crash on a condition that should never happen but might.
203
+ const getVersionFn = deps.getCliVersion ?? getCliVersion;
204
+ let version;
205
+ try {
206
+ version = getVersionFn();
207
+ } catch (err) {
208
+ p.warning(
209
+ `Could not determine CLI version for global install: ${err?.message ?? String(err)}.`,
210
+ );
211
+ printManualSteps(p, detectTransientRunner());
212
+ return result(SessionSyncAction.Skipped);
213
+ }
214
+
215
+ // p.success is silenced under --json; in human mode it tells the
216
+ // user what's about to happen before npm's stream begins.
217
+ p.success(`Running: npm install -g skillrepo@${version}`);
218
+ const installResult = await installSkillrepoGlobally({
219
+ version,
220
+ outputMode: json ? "silent" : "inherit",
221
+ spawn: deps.spawn,
222
+ });
223
+
224
+ if (!installResult.success) {
225
+ p.warning(
226
+ `Could not install skillrepo globally: ${installResult.error}`,
227
+ );
228
+ printManualSteps(p, detectTransientRunner());
229
+ return result(SessionSyncAction.Skipped);
230
+ }
231
+
232
+ p.success(
233
+ `Installed skillrepo@${version} globally (${installResult.binaryPath}).`,
234
+ );
235
+ const r = tryMergeAndPrint({
236
+ global,
237
+ p,
238
+ binaryPath: installResult.binaryPath,
239
+ });
240
+ return { ...r, globalInstallActive: true };
241
+ }
242
+
243
+ // ── Shared helpers ───────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Call `mergeSessionHook` and translate the result into a print +
247
+ * structured return. The optional `binaryPath` bypasses the
248
+ * `isNpxInvocation` guard inside `resolveSkillrepoBinary` (the v3.1.2
249
+ * contract that lets npx-mode init still install a hook with a stable
250
+ * absolute path).
251
+ */
252
+ function tryMergeAndPrint({ global, p, binaryPath }) {
253
+ try {
254
+ const r = mergeSessionHook({ global, binaryPath });
255
+ if (r.action === SessionSyncAction.Installed) {
256
+ p.success(`SessionStart hook installed (${r.path})`);
257
+ } else if (r.action === SessionSyncAction.Updated) {
258
+ p.success(`SessionStart hook updated (${r.path})`);
259
+ } else if (r.action === SessionSyncAction.Unchanged) {
260
+ p.success(`SessionStart hook already installed (${r.path})`);
261
+ } else if (r.action === SessionSyncAction.Skipped) {
262
+ // Reached only by the bare-install path when `which skillrepo`
263
+ // returns nothing (and we didn't auto-install). Surface the
264
+ // helper's actionable reason verbatim.
265
+ p.warning(r.reason ?? "Session sync skipped.");
266
+ }
267
+ return {
268
+ action: r.action,
269
+ path: r.path,
270
+ globalInstallActive: isHookActive(r.action),
271
+ };
272
+ } catch (err) {
273
+ // Disk error (corrupt settings file). The other init steps
274
+ // succeeded — surface and continue.
275
+ p.warning(
276
+ `Session sync failed: ${err?.message ?? String(err)}. ` +
277
+ `Run \`skillrepo session-sync enable\` after fixing the issue.`,
278
+ );
279
+ return result(SessionSyncAction.Failed);
280
+ }
281
+ }
282
+
283
+ function printManualSteps(p, runnerName) {
284
+ // Use the runner's canonical install command (`pnpm add -g`,
285
+ // `bun add -g`, etc.) so the suggestion matches the package
286
+ // manager the user is actually invoking through. Falls back to
287
+ // `npm install -g` when no runner is detected (non-transient
288
+ // failure path) — the universal command that works alongside
289
+ // every runner.
290
+ const installCmd =
291
+ globalInstallCommandFor(runnerName) ?? "npm install -g skillrepo";
292
+ p.warning(
293
+ "To enable session sync later: " +
294
+ `run \`${installCmd}\` (with sudo if needed), then ` +
295
+ "`skillrepo session-sync enable`.",
296
+ );
297
+ }
298
+
299
+ /** Build a default-shaped step-6 result for an action that didn't
300
+ * install anything (opted-out / not-applicable / declined / skipped).
301
+ * Both `path` and `globalInstallActive` are always falsy in those
302
+ * cases — installed/updated/unchanged use `tryMergeAndPrint`'s
303
+ * return shape directly.
304
+ */
305
+ function result(action) {
306
+ return { action, path: null, globalInstallActive: false };
307
+ }
@@ -44,11 +44,15 @@ import { validateAccessKey } from "../lib/http.mjs";
44
44
  import { detectIdes, formatDetectedIdes } from "../lib/detect-ides.mjs";
45
45
  import { readConfig, writeConfig } from "../lib/config.mjs";
46
46
  import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
47
+ import {
48
+ detectTransientRunner,
49
+ globalInstallCommandFor,
50
+ } from "../lib/transient-runners.mjs";
47
51
  import { mergeMcpForVendors, printManualMcpInstructions } from "../lib/mcp-merge.mjs";
48
52
  import { runSync } from "../lib/sync.mjs";
49
53
  import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
50
54
  import { mergeGitignore } from "../lib/mergers/gitignore.mjs";
51
- import { mergeSessionHook } from "../lib/mergers/session-hook.mjs";
55
+ import { installSessionSyncHook } from "./init-session-sync.mjs";
52
56
  import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
53
57
  import {
54
58
  promptSecret,
@@ -138,13 +142,29 @@ const BLACK_HOLE_STREAM = {
138
142
  * @param {object} [io]
139
143
  * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
140
144
  * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
145
+ * @param {object} [deps] - Test-only dependency injection. Production
146
+ * callers leave this empty. Forwarded to step 6
147
+ * (`installSessionSyncHook`); see `init-session-sync.mjs` for
148
+ * the supported keys (`spawn`, `getCliVersion`, `confirmFn`).
141
149
  */
142
- export async function runInit(argv, io = {}) {
150
+ export async function runInit(argv, io = {}, deps = {}) {
143
151
  const stdout = io.stdout ?? process.stdout;
144
152
  const stderr = io.stderr ?? process.stderr;
145
153
 
146
154
  const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
147
155
 
156
+ // `--json` is for non-interactive consumers (CI scripts, programmatic
157
+ // callers). Without `--yes`, init would hang at step 5 (MCP merge
158
+ // per-vendor confirm prompt) waiting for stdin that no one is going
159
+ // to provide. Surface that loudly at parse time rather than letting
160
+ // the user discover it via a 5-minute test-runner timeout.
161
+ if (flags.json && !yes) {
162
+ throw validationError(
163
+ "--json requires --yes (init has interactive prompts that --json suppresses).",
164
+ { hint: "Pass --yes alongside --json for non-interactive runs." },
165
+ );
166
+ }
167
+
148
168
  // In --json mode, suppress all step-progress output so stdout
149
169
  // carries only the final JSON blob.
150
170
  const p = makePrinter(stdout, stderr, flags.json);
@@ -377,99 +397,29 @@ export async function runInit(argv, io = {}) {
377
397
  }
378
398
  p.blank();
379
399
 
380
- // ── Step 6: Session sync (#884) ──────────────────────────────
381
- //
382
- // Install the Claude Code SessionStart hook so the user's library
383
- // auto-syncs on every session start. Per the architect design in
384
- // issue #884:
385
- // - Opt-in by default. Interactive mode prompts before installing.
386
- // `--yes` skips the prompt and installs. `--no-session-sync`
387
- // is the explicit opt-out for BOTH modes — CI bootstraps
388
- // without session sync by passing it.
389
- // - If `which skillrepo` fails (e.g. npx user without a global
390
- // install), we SKIP with a warning. Init continues — do not
391
- // abort for this.
392
- // - A failure writing the settings file is non-fatal: the
393
- // config, MCP, and first sync still run. Users can re-run
394
- // `skillrepo session-sync enable` later.
395
- // - **Skip entirely when Claude Code is not the target.** The
396
- // SessionStart hook is Claude Code-specific: it lives at
397
- // `.claude/settings.local.json` and is only read by Claude
398
- // Code's session-start machinery. A Cursor-only or
399
- // Windsurf-only user doesn't benefit from it and shouldn't
400
- // get a prompt for it. Cross-PR review (v3.1.0) flagged this
401
- // as a silent-useless-state bug: without the guard, the hook
402
- // file was written even for non-Claude projects. The only
403
- // "Claude Code is the target" signals are:
404
- // • `vendors` includes "claudeCode" (either explicit
405
- // `--ide claude` or detected `.claude/` directory), OR
406
- // • `--global` is passed (writes to
407
- // `~/.claude/settings.local.json`, which is explicitly
408
- // Claude Code's user-wide path).
409
- // Everything else → skip with a clear message.
400
+ // ── Step 6: Session sync (#884, v3.1.2 auto-install #894) ─────
410
401
  //
411
- // This step is INSERTED between MCP merge (step 5) and the first
412
- // sync (step 7). Order matters we need the config written
413
- // (step 3) so the hook's `skillrepo update` calls find creds.
402
+ // The full decision tree (six branches) lives in
403
+ // `init-session-sync.mjs`. This step is inserted between MCP
404
+ // merge (step 5) and the first sync (step 7) order matters
405
+ // because the hook's `skillrepo update` calls expect the config
406
+ // to already be written (step 3).
414
407
  p.step(6, 7, "Session sync");
415
- let sessionSyncAction = "skipped";
416
- let sessionSyncPath = null;
417
408
  const claudeTargeted =
418
409
  Boolean(flags.global) ||
419
410
  (Array.isArray(vendors) && vendors.includes("claudeCode"));
420
- if (noSessionSync) {
421
- p.warning("Session sync skipped (--no-session-sync).");
422
- sessionSyncAction = "opted-out";
423
- } else if (!claudeTargeted) {
424
- // Non-Claude-Code target (e.g. `--ide cursor`) — the hook would
425
- // never fire, so skip the prompt AND the install. This is the
426
- // v3.1.0 cross-PR review fix.
427
- p.warning(
428
- "Session sync skipped: the SessionStart hook is Claude Code-specific " +
429
- "and no Claude Code target was configured.",
430
- );
431
- sessionSyncAction = "not-applicable";
432
- } else {
433
- let proceed = true;
434
- if (!yes) {
435
- proceed = await confirm(
436
- "Install Claude Code SessionStart hook so your library auto-syncs on every session start?",
437
- true,
438
- );
439
- }
440
- if (!proceed) {
441
- p.warning(
442
- "Session sync skipped. Run `skillrepo session-sync enable` to install it later.",
443
- );
444
- sessionSyncAction = "declined";
445
- } else {
446
- try {
447
- const result = mergeSessionHook({ global: flags.global });
448
- sessionSyncAction = result.action;
449
- sessionSyncPath = result.path;
450
- if (result.action === "installed") {
451
- p.success(`SessionStart hook installed (${result.path})`);
452
- } else if (result.action === "updated") {
453
- p.success(`SessionStart hook updated (${result.path})`);
454
- } else if (result.action === "unchanged") {
455
- p.success(`SessionStart hook already installed (${result.path})`);
456
- } else if (result.action === "skipped") {
457
- // Binary not resolvable — the installer returned a reason.
458
- p.warning(result.reason ?? "Session sync skipped.");
459
- }
460
- } catch (err) {
461
- // Disk error (corrupt settings file, permissions). The
462
- // other init steps already ran, so the config and MCP are
463
- // still in place. Surface the error and continue to the
464
- // first sync step — do NOT abort.
465
- p.warning(
466
- `Session sync failed: ${err?.message ?? String(err)}. ` +
467
- `Run \`skillrepo session-sync enable\` after fixing the issue.`,
468
- );
469
- sessionSyncAction = "failed";
470
- }
471
- }
472
- }
411
+ const sessionSync = await installSessionSyncHook({
412
+ noSessionSync,
413
+ claudeTargeted,
414
+ yes,
415
+ json: flags.json,
416
+ global: flags.global,
417
+ p,
418
+ deps,
419
+ });
420
+ const sessionSyncAction = sessionSync.action;
421
+ const sessionSyncPath = sessionSync.path;
422
+ const globalInstallActive = sessionSync.globalInstallActive;
473
423
  p.blank();
474
424
 
475
425
  // ── Step 7: First sync ───────────────────────────────────────
@@ -519,16 +469,46 @@ export async function runInit(argv, io = {}) {
519
469
  updated: 0,
520
470
  removed: 0,
521
471
  notModified: false,
472
+ // On a synthesized failure summary we genuinely don't know
473
+ // whether the sync WOULD have been full or delta — the network
474
+ // call never completed. Architect review (v3.1.1) flagged that
475
+ // emitting `fullSync: false` here is misleading for --json
476
+ // consumers: it looks like a legitimate "delta sync returned
477
+ // zero" signal. Using `null` makes the unknown-state
478
+ // explicit — any typed consumer must handle it separately
479
+ // from true/false. The always-present `sync.failureReason`
480
+ // field is still the authoritative "did the sync fail"
481
+ // indicator; fullSync is just additional context.
482
+ fullSync: null,
522
483
  syncedAt: new Date().toISOString(),
523
484
  };
524
485
  }
525
486
 
487
+ const zeroDeltas =
488
+ syncSummary.added + syncSummary.updated + syncSummary.removed === 0;
489
+
526
490
  if (syncFailedReason) {
527
491
  // The warning already printed; the step-summary success line
528
492
  // would be misleading, so we skip it. Any helpful "next steps"
529
493
  // is in the final `SkillRepo is ready` block.
530
- } else if (syncSummary.notModified || syncSummary.added + syncSummary.updated + syncSummary.removed === 0) {
494
+ } else if (syncSummary.notModified) {
495
+ // 304 Not Modified — the client had the current ETag already.
496
+ // Definitively "up to date" regardless of whether the library
497
+ // is empty or populated.
498
+ p.success("Library is up to date.");
499
+ } else if (zeroDeltas && syncSummary.fullSync) {
500
+ // Full sync (no prior .last-sync state existed) with zero
501
+ // results — the account's library is genuinely empty.
531
502
  p.success("No skills in library yet (add some with `skillrepo add @owner/name`)");
503
+ } else if (zeroDeltas) {
504
+ // Delta sync with zero results — nothing changed since the
505
+ // last sync. Could be zero skills total, or N skills all
506
+ // unchanged. Without a full-sync roundtrip we can't tell, so
507
+ // the accurate phrasing is "no changes." Before this fix, the
508
+ // init step-7 message conflated this with the truly-empty
509
+ // case, which lied to any user who had skills but had already
510
+ // synced them on a prior run.
511
+ p.success("Library is up to date (no changes since last sync).");
532
512
  } else {
533
513
  p.success(
534
514
  `${syncSummary.added} added, ${syncSummary.updated} updated, ${syncSummary.removed} removed`,
@@ -554,13 +534,9 @@ export async function runInit(argv, io = {}) {
554
534
  skipped: skipped.map((r) => r.path),
555
535
  failed: failed.map((r) => ({ path: r.path, reason: r.reason })),
556
536
  },
557
- // Session-sync block action values:
558
- // "installed" | "updated" | "unchanged" (success states)
559
- // "opted-out" (--no-session-sync)
560
- // "declined" (user said no at the prompt)
561
- // "not-applicable" (no Claude Code target — e.g. `--ide cursor`)
562
- // "skipped" (binary not resolvable — reason in non-json path)
563
- // "failed" (disk error during install)
537
+ // Session-sync block. `action` is one of the
538
+ // `SessionSyncAction` enum values defined in
539
+ // `./session-sync-actions.mjs` (single source of truth).
564
540
  sessionSync: {
565
541
  action: sessionSyncAction,
566
542
  path: sessionSyncPath,
@@ -581,10 +557,44 @@ export async function runInit(argv, io = {}) {
581
557
  }
582
558
 
583
559
  stdout.write("\n ✓ SkillRepo is ready.\n\n");
560
+ // Pick the command prefix the user can actually run. The user is
561
+ // either:
562
+ // - running a stable global install → bare `skillrepo list` works
563
+ // - running via a transient runner (npx/pnpx/yarn dlx/bunx) AND
564
+ // the auto-install in step 6 put a global on PATH
565
+ // (`globalInstallActive`) → bare `skillrepo list` ALSO works
566
+ // (subject to PATH refresh on Windows; see the
567
+ // path-not-updated branch in global-install.mjs)
568
+ // - running via a transient runner with NO global → bare
569
+ // `skillrepo` would fail; they need to invoke through their
570
+ // runner. v3.1.2 detects WHICH runner (npx/pnpx/yarn dlx/bunx)
571
+ // so the prefix matches what they actually used.
572
+ const transientRunner = detectTransientRunner();
573
+ const showRunnerPrefix = transientRunner && !globalInstallActive;
574
+ const prefix = showRunnerPrefix ? `${transientRunner} skillrepo` : "skillrepo";
584
575
  stdout.write(" Next steps:\n");
585
- stdout.write("skillrepo list — see what's in your library\n");
586
- stdout.write("skillrepo search <query> — find skills\n");
587
- stdout.write("skillrepo add @owner/name — add a skill\n\n");
576
+ stdout.write(`${prefix} list — see what's in your library\n`);
577
+ stdout.write(`${prefix} search <query> — find skills\n`);
578
+ stdout.write(`${prefix} add @owner/name — add a skill\n`);
579
+ if (showRunnerPrefix) {
580
+ // The Tip is only useful when the user is still on a transient
581
+ // runner post-init: it tells them how to switch to a faster,
582
+ // sync-enabled global install. After a successful auto-install
583
+ // (or when a pre-existing global was used), the global IS active
584
+ // and the tip would be stale.
585
+ //
586
+ // Use the runner's CANONICAL global-install command so a `pnpx`
587
+ // user gets `pnpm add -g skillrepo` instead of `npm install -g
588
+ // skillrepo`. Falls back to the universal `npm install -g` for
589
+ // any unrecognized runner.
590
+ const installCmd =
591
+ globalInstallCommandFor(transientRunner) ?? "npm install -g skillrepo";
592
+ stdout.write(
593
+ `\n Tip: \`${installCmd}\` for faster commands ` +
594
+ "and session-start sync.\n",
595
+ );
596
+ }
597
+ stdout.write("\n");
588
598
  }
589
599
 
590
600
  /**