skillrepo 3.1.1 → 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.
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,9 @@ 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
- **Requires a stable global install.** The hook is skipped (with a clear warning) when `skillrepo init` is invoked via `npx`, because the npx cache path is transient and the baked-in hook command would break on the next cache eviction. Install globally with `npm install -g skillrepo` before running `session-sync enable`.
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.
146
148
 
147
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.
148
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "3.1.1",
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
+ }
@@ -43,12 +43,16 @@
43
43
  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
- import { resolveFlags, effectiveVendors, isNpxInvocation } from "../lib/cli-config.mjs";
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 ───────────────────────────────────────
@@ -584,13 +534,9 @@ export async function runInit(argv, io = {}) {
584
534
  skipped: skipped.map((r) => r.path),
585
535
  failed: failed.map((r) => ({ path: r.path, reason: r.reason })),
586
536
  },
587
- // Session-sync block action values:
588
- // "installed" | "updated" | "unchanged" (success states)
589
- // "opted-out" (--no-session-sync)
590
- // "declined" (user said no at the prompt)
591
- // "not-applicable" (no Claude Code target — e.g. `--ide cursor`)
592
- // "skipped" (binary not resolvable — reason in non-json path)
593
- // "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).
594
540
  sessionSync: {
595
541
  action: sessionSyncAction,
596
542
  path: sessionSyncPath,
@@ -611,24 +557,41 @@ export async function runInit(argv, io = {}) {
611
557
  }
612
558
 
613
559
  stdout.write("\n ✓ SkillRepo is ready.\n\n");
614
- // Pick the command prefix the user can actually run. If they
615
- // invoked init via `npx skillrepo ...`, bare `skillrepo list` will
616
- // fail with "command not found" they need `npx skillrepo list`.
617
- // Under a global install, the bare command is correct. We default
618
- // to bare and add the `npx` prefix ONLY when we can detect the
619
- // current invocation is npx.
620
- const prefix = isNpxInvocation() ? "npx skillrepo" : "skillrepo";
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";
621
575
  stdout.write(" Next steps:\n");
622
576
  stdout.write(` • ${prefix} list — see what's in your library\n`);
623
577
  stdout.write(` • ${prefix} search <query> — find skills\n`);
624
578
  stdout.write(` • ${prefix} add @owner/name — add a skill\n`);
625
- if (isNpxInvocation()) {
626
- // Soft recommendation: running under npx works but every command
627
- // re-downloads the package. Global install is faster AND enables
628
- // the session-sync feature (which requires a stable binary path).
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";
629
592
  stdout.write(
630
- "\n Tip: `npm install -g skillrepo` for faster commands " +
631
- "and to enable session-start sync.\n",
593
+ `\n Tip: \`${installCmd}\` for faster commands ` +
594
+ "and session-start sync.\n",
632
595
  );
633
596
  }
634
597
  stdout.write("\n");
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Single source of truth for the `sessionSync.action` enum surfaced
3
+ * by `skillrepo init` (#894 / v3.1.2).
4
+ *
5
+ * The enum appears in three places that MUST stay in sync:
6
+ * - the `--json` output's `sessionSync.action` field (consumer
7
+ * contract for CI scripts and programmatic callers)
8
+ * - the human-readable summary at the end of init
9
+ * - the README's `init` flag table
10
+ *
11
+ * Before this module existed, the values were declared inline in a
12
+ * JSDoc comment block and re-typed as string literals at every
13
+ * assignment site — drift risk. Centralizing here means adding a
14
+ * new value is one edit (this file + the consumer that needs it),
15
+ * not a grep-and-pray pass across init.mjs.
16
+ *
17
+ * The enum is `Object.freeze`d so callers can't mutate it. JS
18
+ * doesn't have first-class enums; this is the standard idiom.
19
+ */
20
+
21
+ /**
22
+ * @typedef {"installed" | "updated" | "unchanged" | "opted-out" | "declined" | "not-applicable" | "skipped" | "failed"} SessionSyncActionValue
23
+ */
24
+
25
+ /**
26
+ * Action enum constants. Use these instead of string literals at
27
+ * assignment sites. Values:
28
+ *
29
+ * - `Installed` — fresh hook write
30
+ * - `Updated` — existing hook found, command differed,
31
+ * replaced in place (e.g. binary path changed)
32
+ * - `Unchanged` — identical hook already present, no write
33
+ * - `OptedOut` — `--no-session-sync` was passed
34
+ * - `Declined` — user said no at the prompt
35
+ * - `NotApplicable` — Claude Code wasn't a vendor target (the hook
36
+ * is Claude-specific, would never fire)
37
+ * - `Skipped` — install offer was accepted but the install
38
+ * failed (npm error, version-read error,
39
+ * prerequisite missing). Distinct from
40
+ * `OptedOut` (user explicit) and `Declined`
41
+ * (user said no).
42
+ * - `Failed` — disk error while writing the settings file
43
+ * (corrupt JSON, permissions, etc.)
44
+ */
45
+ export const SessionSyncAction = Object.freeze({
46
+ Installed: "installed",
47
+ Updated: "updated",
48
+ Unchanged: "unchanged",
49
+ OptedOut: "opted-out",
50
+ Declined: "declined",
51
+ NotApplicable: "not-applicable",
52
+ Skipped: "skipped",
53
+ Failed: "failed",
54
+ });
55
+
56
+ /**
57
+ * True when the action represents a state where the SessionStart
58
+ * hook is currently in place on disk (and therefore the global
59
+ * `skillrepo` binary referenced by the hook is also active on PATH,
60
+ * because `mergeSessionHook` only emits these actions when it
61
+ * successfully wrote/found a working hook).
62
+ *
63
+ * Used by `init` to suppress the now-stale "install globally"
64
+ * Next-Steps tip after step 6 has put a hook in place.
65
+ *
66
+ * @param {string} action
67
+ * @returns {boolean}
68
+ */
69
+ export function isHookActive(action) {
70
+ return (
71
+ action === SessionSyncAction.Installed ||
72
+ action === SessionSyncAction.Updated ||
73
+ action === SessionSyncAction.Unchanged
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Frozen array of all valid `sessionSync.action` string values.
79
+ * Useful for runtime validation in tests and for the JSON-schema-
80
+ * style documentation generator.
81
+ *
82
+ * Built from `Object.values(SessionSyncAction)` so it stays in sync
83
+ * automatically. We use a frozen array (rather than a Set) because
84
+ * `Object.freeze` on a Set does NOT prevent `.add()` from mutating
85
+ * the internal storage — JS frozen-Set semantics are surprising.
86
+ * Frozen arrays ARE immutable end-to-end. Membership checks are
87
+ * `.includes()` instead of `.has()`; the values list is small (8)
88
+ * so the linear scan is irrelevant.
89
+ */
90
+ export const SESSION_SYNC_ACTION_VALUES = Object.freeze(
91
+ Object.values(SessionSyncAction),
92
+ );