skillrepo 3.0.0 → 3.1.1

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 (52) hide show
  1. package/README.md +74 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +184 -19
  5. package/src/commands/remove.mjs +8 -13
  6. package/src/commands/session-sync.mjs +152 -0
  7. package/src/commands/uninstall.mjs +484 -0
  8. package/src/commands/update.mjs +125 -8
  9. package/src/lib/artifact-registry.mjs +305 -0
  10. package/src/lib/cli-config.mjs +78 -0
  11. package/src/lib/config.mjs +6 -3
  12. package/src/lib/file-write.mjs +8 -3
  13. package/src/lib/fs-utils.mjs +90 -9
  14. package/src/lib/mergers/session-hook.mjs +378 -0
  15. package/src/lib/paths.mjs +21 -0
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/removers/claude-mcp.mjs +67 -0
  18. package/src/lib/removers/cursor-mcp.mjs +60 -0
  19. package/src/lib/removers/env-local.mjs +55 -0
  20. package/src/lib/removers/gitignore.mjs +108 -0
  21. package/src/lib/removers/settings.mjs +183 -0
  22. package/src/lib/removers/vscode-mcp.mjs +87 -0
  23. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  24. package/src/lib/sync.mjs +26 -0
  25. package/src/test/commands/add.test.mjs +10 -4
  26. package/src/test/commands/get.test.mjs +10 -4
  27. package/src/test/commands/init.test.mjs +428 -4
  28. package/src/test/commands/list.test.mjs +10 -4
  29. package/src/test/commands/remove.test.mjs +10 -4
  30. package/src/test/commands/search.test.mjs +10 -4
  31. package/src/test/commands/session-sync.test.mjs +352 -0
  32. package/src/test/commands/uninstall.test.mjs +774 -0
  33. package/src/test/commands/update.test.mjs +168 -4
  34. package/src/test/helpers/sandbox-home.mjs +161 -0
  35. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  36. package/src/test/integration/file-write.integration.test.mjs +10 -4
  37. package/src/test/lib/artifact-registry.test.mjs +268 -0
  38. package/src/test/lib/cli-config.test.mjs +126 -5
  39. package/src/test/lib/config.test.mjs +10 -4
  40. package/src/test/lib/file-write.test.mjs +24 -10
  41. package/src/test/lib/mcp-merge.test.mjs +10 -4
  42. package/src/test/lib/paths.test.mjs +10 -4
  43. package/src/test/lib/platform.test.mjs +135 -0
  44. package/src/test/lib/sync.test.mjs +20 -4
  45. package/src/test/mergers/session-hook.test.mjs +1175 -0
  46. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  47. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  48. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  49. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  50. package/src/test/mergers/uninstall-settings.test.mjs +296 -0
  51. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  52. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +128 -0
package/README.md CHANGED
@@ -41,22 +41,24 @@ Requires Node.js 18 or later.
41
41
 
42
42
  ### `init` — first-run setup
43
43
 
44
- Validates your access key, detects installed IDEs, writes the MCP config,
45
- and runs the first library sync.
46
-
47
44
  ```sh
48
- skillrepo init [--key <key>] [--url <url>] [--yes] [--force] [--ide <list>] [--global] [--json]
45
+ skillrepo init [--key <key>] [--url <url>] [--yes] [--force] [--ide <list>] [--global] [--json] [--no-session-sync]
49
46
  ```
50
47
 
48
+ Validates your access key, detects installed IDEs, writes the MCP config,
49
+ installs the Claude Code SessionStart hook (opt-in — see `session-sync`
50
+ below), and runs the first library sync.
51
+
51
52
  | Flag | Description |
52
53
  |------|-------------|
53
54
  | `--key, -k <key>` | Access key. Falls back to `SKILLREPO_ACCESS_KEY` env var, then interactive prompt. |
54
55
  | `--url, -u <url>` | Server URL. Defaults to `https://skillrepo.dev`. Use for self-hosted. |
55
- | `--yes, -y` | Non-interactive. Skip all confirmation prompts. Required for CI. |
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
57
  | `--force` | Re-prompt for a new key even if `~/.claude/skillrepo/config.json` is valid. |
57
58
  | `--ide <list>` | Comma-separated vendor override. One or more of `claude`, `cursor`, `windsurf`, `vscode`, or `all`. |
58
59
  | `--global` | Write skills to `~/.claude/skills/` (personal) instead of `.claude/skills/` (project). |
59
- | `--json` | Emit a structured JSON summary on success. |
60
+ | `--no-session-sync` | Skip step 6 (SessionStart hook install). Works in both interactive and `--yes` modes. Use for CI scripts that bootstrap a project without ever starting a Claude Code session. |
61
+ | `--json` | Emit a structured JSON summary on success. Includes a `sessionSync: { action, path }` block describing whether the hook was installed. |
60
62
 
61
63
  `init` is idempotent: re-running with a valid existing config re-runs
62
64
  detection + MCP merge + first sync without re-prompting for a key. If the
@@ -129,6 +131,72 @@ DELETEs from `/api/v1/library` and deletes the local directory. Requires
129
131
  a write-scoped access key. The local delete is immediate and does not
130
132
  wait for a follow-up sync.
131
133
 
134
+ ### `session-sync` — auto-sync on Claude Code session start
135
+
136
+ ```sh
137
+ skillrepo session-sync enable [--global] [--json]
138
+ skillrepo session-sync disable [--global] [--json]
139
+ ```
140
+
141
+ Installs (or removes) a Claude Code [SessionStart hook](https://docs.claude.com/en/docs/claude-code/hooks) that calls `skillrepo update` every time you open a Claude Code session — keeping your library current without you remembering to sync manually. The hook lives in `.claude/settings.local.json` (per-developer, gitignored by default) and runs your existing globally-installed `skillrepo` binary.
142
+
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
+
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`.
146
+
147
+ **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
+
149
+ **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.
150
+
151
+ Flags:
152
+
153
+ - `--global` — operates on `~/.claude/settings.local.json` so the hook fires in every Claude Code session across all projects on your machine.
154
+ - `--json` — emit structured JSON with `action`, `path`, and `command` fields for scripting.
155
+
156
+ ### `uninstall` — remove SkillRepo from a project
157
+
158
+ ```sh
159
+ skillrepo uninstall [--dry-run] [--yes] [--global] [--json]
160
+ ```
161
+
162
+ Surgically removes every SkillRepo artifact from the current project:
163
+
164
+ - `mcpServers.skillrepo` from `.mcp.json`, `.cursor/mcp.json`,
165
+ and `.vscode/mcp.json` (plus the matching `inputs` prompt in the
166
+ VS Code config)
167
+ - `SKILLREPO_ACCESS_KEY=...` lines from `.env.local`
168
+ - The SkillRepo section of `.gitignore`
169
+ - The SkillRepo `SessionStart` hook from `.claude/settings.local.json`
170
+ (if present)
171
+ - The `.claude/skills/` directory
172
+
173
+ Non-SkillRepo entries in shared files are preserved. Runs offline — no
174
+ server call required, so a revoked or missing access key is not a
175
+ problem. Interactive by default: the command prints a full list of what
176
+ will be removed and prompts for confirmation before touching anything.
177
+
178
+ With `--global`, also removes:
179
+
180
+ - `mcpServers.skillrepo` from `~/.codeium/windsurf/mcp_config.json`
181
+ - The `~/.claude/skills/` global skill cache
182
+ - The `~/.claude/skillrepo/` directory (stored credentials + sync cache)
183
+
184
+ Flags:
185
+
186
+ - `--dry-run` / `-n` — print what would be removed and exit without
187
+ touching any file.
188
+ - `--yes` / `-y` — skip the confirmation prompt.
189
+ - `--global` — also remove user-global state. By default the command
190
+ leaves your credential and other projects' integrations untouched.
191
+ - `--json` — emit structured JSON instead of human output. The summary
192
+ includes `removed[]` and `errors[]` arrays suitable for scripting.
193
+
194
+ The command is idempotent — a second run with nothing left to remove
195
+ exits 0 and reports "Nothing to remove." If any artifact fails to
196
+ remove (e.g. a file is read-only), the command continues processing
197
+ the others, surfaces every error at the end, and exits with code 3
198
+ (disk error).
199
+
132
200
  ## Configuration
133
201
 
134
202
  ### Credentials
package/bin/skillrepo.mjs CHANGED
@@ -28,6 +28,8 @@ import { runAdd } from "../src/commands/add.mjs";
28
28
  import { runRemove } from "../src/commands/remove.mjs";
29
29
  import { runList } from "../src/commands/list.mjs";
30
30
  import { runSearch } from "../src/commands/search.mjs";
31
+ import { runUninstall } from "../src/commands/uninstall.mjs";
32
+ import { runSessionSync } from "../src/commands/session-sync.mjs";
31
33
  import { CliError, EXIT_OK, EXIT_VALIDATION } from "../src/lib/errors.mjs";
32
34
 
33
35
  // ── Command registry ────────────────────────────────────────────────────
@@ -75,6 +77,18 @@ const COMMANDS = {
75
77
  usage: "skillrepo search <query> [--limit <n>] [--json] [--semantic]",
76
78
  run: async (argv) => runSearch(argv),
77
79
  },
80
+ uninstall: {
81
+ description:
82
+ "Remove SkillRepo from this project (and optionally global state with --global)",
83
+ usage: "skillrepo uninstall [--dry-run] [--yes] [--global] [--json]",
84
+ run: async (argv) => runUninstall(argv),
85
+ },
86
+ "session-sync": {
87
+ description:
88
+ "Enable or disable the Claude Code SessionStart hook that auto-syncs your library",
89
+ usage: "skillrepo session-sync <enable|disable> [--global] [--json]",
90
+ run: async (argv) => runSessionSync(argv),
91
+ },
78
92
  };
79
93
 
80
94
  const COMMAND_NAMES = Object.keys(COMMANDS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
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": {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `skillrepo init` (#673) — PR3b rewrite.
2
+ * `skillrepo init` (#673) — PR3b rewrite, v3.1.0 session-sync (#884).
3
3
  *
4
4
  * First-run command. Replaces the v2.0.0 init that consumed the
5
5
  * deprecated `/api/v1/setup` endpoint and wrote hook-delivery
@@ -10,8 +10,9 @@
10
10
  * 3. Write ~/.claude/skillrepo/config.json via config.mjs
11
11
  * 4. Detect installed IDEs (.claude/, .cursor/, .vscode/, ~/.codeium/windsurf/)
12
12
  * 5. Run MCP auto-merge for detected IDEs (user-confirmed unless --yes)
13
- * 6. Run the first library sync via sync.mjs
14
- * 7. Print summary
13
+ * 6. Install Claude Code SessionStart hook (v3.1.0 #884)
14
+ * 7. Run the first library sync via sync.mjs
15
+ * 8. Print summary
15
16
  *
16
17
  * Key differences from v2.0.0:
17
18
  *
@@ -42,11 +43,12 @@
42
43
  import { validateAccessKey } from "../lib/http.mjs";
43
44
  import { detectIdes, formatDetectedIdes } from "../lib/detect-ides.mjs";
44
45
  import { readConfig, writeConfig } from "../lib/config.mjs";
45
- import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
46
+ import { resolveFlags, effectiveVendors, isNpxInvocation } from "../lib/cli-config.mjs";
46
47
  import { mergeMcpForVendors, printManualMcpInstructions } from "../lib/mcp-merge.mjs";
47
48
  import { runSync } from "../lib/sync.mjs";
48
49
  import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
49
50
  import { mergeGitignore } from "../lib/mergers/gitignore.mjs";
51
+ import { mergeSessionHook } from "../lib/mergers/session-hook.mjs";
50
52
  import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
51
53
  import {
52
54
  promptSecret,
@@ -141,7 +143,7 @@ export async function runInit(argv, io = {}) {
141
143
  const stdout = io.stdout ?? process.stdout;
142
144
  const stderr = io.stderr ?? process.stderr;
143
145
 
144
- const { flags, yes, force } = parseInitFlags(argv);
146
+ const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
145
147
 
146
148
  // In --json mode, suppress all step-progress output so stdout
147
149
  // carries only the final JSON blob.
@@ -150,7 +152,7 @@ export async function runInit(argv, io = {}) {
150
152
  p.header("SkillRepo Init");
151
153
 
152
154
  // ── Step 1: Collect credentials ───────────────────────────────
153
- p.step(1, 6, "Credentials");
155
+ p.step(1, 7, "Credentials");
154
156
 
155
157
  // Try sources in priority: --key flag > --url flag > global
156
158
  // config > env vars > interactive prompt.
@@ -202,7 +204,7 @@ export async function runInit(argv, io = {}) {
202
204
  p.blank();
203
205
 
204
206
  // ── Step 2: Validate against the server ──────────────────────
205
- p.step(2, 6, "Validating key");
207
+ p.step(2, 7, "Validating key");
206
208
  let accountCtx;
207
209
  try {
208
210
  accountCtx = await validateAccessKey(serverUrl, apiKey);
@@ -232,7 +234,7 @@ export async function runInit(argv, io = {}) {
232
234
  p.blank();
233
235
 
234
236
  // ── Step 3: Write global config ──────────────────────────────
235
- p.step(3, 6, "Writing config");
237
+ p.step(3, 7, "Writing config");
236
238
  const configAction = writeConfig({
237
239
  apiKey,
238
240
  serverUrl,
@@ -290,7 +292,7 @@ export async function runInit(argv, io = {}) {
290
292
  p.blank();
291
293
 
292
294
  // ── Step 4: Detect IDEs ──────────────────────────────────────
293
- p.step(4, 6, "Detecting IDEs");
295
+ p.step(4, 7, "Detecting IDEs");
294
296
 
295
297
  // The v2.0.0 CLI had a silent fallback to [claudeCode, cursor]
296
298
  // when nothing was detected. The v3.0.0 CLI removes that — the
@@ -343,7 +345,7 @@ export async function runInit(argv, io = {}) {
343
345
  p.blank();
344
346
 
345
347
  // ── Step 5: MCP auto-merge ───────────────────────────────────
346
- p.step(5, 6, "Configuring MCP");
348
+ p.step(5, 7, "Configuring MCP");
347
349
  const mcpUrl = `${serverUrl}/api/mcp`;
348
350
  // In --json mode, pass a black-hole stdout to mergeMcpForVendors
349
351
  // so its per-vendor preview lines don't pollute the JSON output.
@@ -375,8 +377,103 @@ export async function runInit(argv, io = {}) {
375
377
  }
376
378
  p.blank();
377
379
 
378
- // ── Step 6: First sync ───────────────────────────────────────
379
- p.step(6, 6, "Pulling library");
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.
410
+ //
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.
414
+ p.step(6, 7, "Session sync");
415
+ let sessionSyncAction = "skipped";
416
+ let sessionSyncPath = null;
417
+ const claudeTargeted =
418
+ Boolean(flags.global) ||
419
+ (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
+ }
473
+ p.blank();
474
+
475
+ // ── Step 7: First sync ───────────────────────────────────────
476
+ p.step(7, 7, "Pulling library");
380
477
  let syncSummary;
381
478
  let syncFailedReason = null;
382
479
  try {
@@ -422,16 +519,46 @@ export async function runInit(argv, io = {}) {
422
519
  updated: 0,
423
520
  removed: 0,
424
521
  notModified: false,
522
+ // On a synthesized failure summary we genuinely don't know
523
+ // whether the sync WOULD have been full or delta — the network
524
+ // call never completed. Architect review (v3.1.1) flagged that
525
+ // emitting `fullSync: false` here is misleading for --json
526
+ // consumers: it looks like a legitimate "delta sync returned
527
+ // zero" signal. Using `null` makes the unknown-state
528
+ // explicit — any typed consumer must handle it separately
529
+ // from true/false. The always-present `sync.failureReason`
530
+ // field is still the authoritative "did the sync fail"
531
+ // indicator; fullSync is just additional context.
532
+ fullSync: null,
425
533
  syncedAt: new Date().toISOString(),
426
534
  };
427
535
  }
428
536
 
537
+ const zeroDeltas =
538
+ syncSummary.added + syncSummary.updated + syncSummary.removed === 0;
539
+
429
540
  if (syncFailedReason) {
430
541
  // The warning already printed; the step-summary success line
431
542
  // would be misleading, so we skip it. Any helpful "next steps"
432
543
  // is in the final `SkillRepo is ready` block.
433
- } else if (syncSummary.notModified || syncSummary.added + syncSummary.updated + syncSummary.removed === 0) {
544
+ } else if (syncSummary.notModified) {
545
+ // 304 Not Modified — the client had the current ETag already.
546
+ // Definitively "up to date" regardless of whether the library
547
+ // is empty or populated.
548
+ p.success("Library is up to date.");
549
+ } else if (zeroDeltas && syncSummary.fullSync) {
550
+ // Full sync (no prior .last-sync state existed) with zero
551
+ // results — the account's library is genuinely empty.
434
552
  p.success("No skills in library yet (add some with `skillrepo add @owner/name`)");
553
+ } else if (zeroDeltas) {
554
+ // Delta sync with zero results — nothing changed since the
555
+ // last sync. Could be zero skills total, or N skills all
556
+ // unchanged. Without a full-sync roundtrip we can't tell, so
557
+ // the accurate phrasing is "no changes." Before this fix, the
558
+ // init step-7 message conflated this with the truly-empty
559
+ // case, which lied to any user who had skills but had already
560
+ // synced them on a prior run.
561
+ p.success("Library is up to date (no changes since last sync).");
435
562
  } else {
436
563
  p.success(
437
564
  `${syncSummary.added} added, ${syncSummary.updated} updated, ${syncSummary.removed} removed`,
@@ -457,6 +584,17 @@ export async function runInit(argv, io = {}) {
457
584
  skipped: skipped.map((r) => r.path),
458
585
  failed: failed.map((r) => ({ path: r.path, reason: r.reason })),
459
586
  },
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)
594
+ sessionSync: {
595
+ action: sessionSyncAction,
596
+ path: sessionSyncPath,
597
+ },
460
598
  // Sync block always shows the counts (zeroed on failure)
461
599
  // and adds `failureReason` when the first sync blew up —
462
600
  // downstream scripts can `.failureReason != null` to
@@ -473,10 +611,27 @@ export async function runInit(argv, io = {}) {
473
611
  }
474
612
 
475
613
  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";
476
621
  stdout.write(" Next steps:\n");
477
- stdout.write("skillrepo list — see what's in your library\n");
478
- stdout.write("skillrepo search <query> — find skills\n");
479
- stdout.write("skillrepo add @owner/name — add a skill\n\n");
622
+ stdout.write(`${prefix} list — see what's in your library\n`);
623
+ stdout.write(`${prefix} search <query> — find skills\n`);
624
+ 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).
629
+ stdout.write(
630
+ "\n Tip: `npm install -g skillrepo` for faster commands " +
631
+ "and to enable session-start sync.\n",
632
+ );
633
+ }
634
+ stdout.write("\n");
480
635
  }
481
636
 
482
637
  /**
@@ -486,10 +641,12 @@ export async function runInit(argv, io = {}) {
486
641
  function parseInitFlags(argv) {
487
642
  // resolveFlags handles --key/--url/--global/--ide/--json and
488
643
  // rejects unknown flags via its acceptPositional callback. We
489
- // intercept --yes and --force as "positional-shaped" flags via
490
- // the callback so we don't need to pre-filter argv.
644
+ // intercept --yes, --force, and --no-session-sync as "positional-
645
+ // shaped" flags via the callback so we don't need to pre-filter
646
+ // argv.
491
647
  let yes = false;
492
648
  let force = false;
649
+ let noSessionSync = false;
493
650
 
494
651
  const flags = resolveFlags(argv, {
495
652
  requireAuth: false, // init MAY prompt for a key, so don't hard-fail
@@ -510,9 +667,17 @@ function parseInitFlags(argv) {
510
667
  force = true;
511
668
  return 1;
512
669
  }
670
+ if (arg === "--no-session-sync") {
671
+ // #884: explicit opt-out for BOTH interactive and --yes
672
+ // modes. CI scripts that bootstrap a project without ever
673
+ // starting a Claude Code session pass this to skip the hook
674
+ // installation entirely.
675
+ noSessionSync = true;
676
+ return 1;
677
+ }
513
678
  return false; // anything else is unknown
514
679
  },
515
680
  });
516
681
 
517
- return { flags, yes, force };
682
+ return { flags, yes, force, noSessionSync };
518
683
  }
@@ -11,19 +11,14 @@
11
11
  * Why direct local delete instead of calling sync.mjs to process
12
12
  * the tombstone:
13
13
  *
14
- * - The server's ETag computation does NOT currently factor in
15
- * `libraryRemovals`, so a conditional sync after the DELETE
16
- * would return 304 and the CLI would never see the tombstone.
17
- * The CLI would leave the orphaned skill files on disk until
18
- * some unrelated mutation bumps the ETag. This is documented
19
- * in follow-up #875.
20
- * - A direct local delete avoids that problem entirely. Since
21
- * `remove` is called with a specific (owner, name) the CLI
22
- * already knows exactly what to delete — there's no reason to
23
- * go through a tombstone round-trip.
24
- * - Cross-machine remove still works via the tombstone-on-server
25
- * path (once #875 lands); `remove` just short-circuits the
26
- * single-machine case.
14
+ * - `remove` is called with a specific (owner, name), so the CLI
15
+ * already knows exactly what to delete. Going through a full
16
+ * sync round-trip to rediscover the tombstone is wasteful
17
+ * a direct unlink is the shortest correct path.
18
+ * - Cross-machine remove propagation is handled by the sync
19
+ * path: the ETag factors in `libraryRemovals` (fixed in #875)
20
+ * so a conditional GET from another machine correctly
21
+ * invalidates and returns the tombstone list.
27
22
  *
28
23
  * Idempotent semantics:
29
24
  *
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `skillrepo session-sync enable|disable` (#884).
3
+ *
4
+ * Thin command wrapper over the session-hook installer/remover in
5
+ * `src/lib/mergers/session-hook.mjs`. The command exists so users who
6
+ * want to toggle session-start sync WITHOUT re-running `skillrepo init`
7
+ * can do so with a single command. `init` calls the same underlying
8
+ * `mergeSessionHook` helper at its step 6.
9
+ *
10
+ * Usage:
11
+ * skillrepo session-sync enable — install the SessionStart hook
12
+ * skillrepo session-sync disable — remove it
13
+ * skillrepo session-sync status — print current state (future — not wired yet)
14
+ *
15
+ * Flags:
16
+ * --global Operate on ~/.claude/settings.local.json instead of the
17
+ * project-local file. Mirrors `init --global` semantics.
18
+ * --json Emit structured JSON instead of human output.
19
+ *
20
+ * Exit codes:
21
+ * 0 success (installed, updated, unchanged, removed, or already disabled)
22
+ * 3 disk error (cannot read/write the settings file)
23
+ * 5 validation error (bad subcommand or flag combination)
24
+ *
25
+ * This command does NOT share the `update --session-hook` exit-0-on-
26
+ * errors contract. That contract exists specifically for the hook
27
+ * runner's startup path; when a user explicitly invokes
28
+ * `skillrepo session-sync enable` at the shell, they expect a
29
+ * non-zero exit on failure so their shell script knows something
30
+ * went wrong.
31
+ */
32
+
33
+ import {
34
+ mergeSessionHook,
35
+ removeSessionHook,
36
+ } from "../lib/mergers/session-hook.mjs";
37
+ import { resolveFlags } from "../lib/cli-config.mjs";
38
+ import { validationError } from "../lib/errors.mjs";
39
+
40
+ /**
41
+ * Run `session-sync <subcommand>`. Throws CliError on failure; the
42
+ * dispatcher maps to the appropriate exit code.
43
+ *
44
+ * @param {string[]} argv
45
+ * @param {object} [io]
46
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
47
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
48
+ */
49
+ export async function runSessionSync(argv, io = {}) {
50
+ const stdout = io.stdout ?? process.stdout;
51
+
52
+ // ── Subcommand + flag parsing ───────────────────────────────────
53
+ //
54
+ // The subcommand is the first positional arg — "enable" or
55
+ // "disable". Use resolveFlags' acceptPositional callback to
56
+ // consume it (same pattern `get`, `search`, `add`, `remove` use
57
+ // for their positional args).
58
+ let subcommand = null;
59
+ const flags = resolveFlags(argv, {
60
+ requireAuth: false, // no server call — key not needed
61
+ skipConfig: true, // don't read the global config file
62
+ acceptPositional(arg) {
63
+ if (arg === "enable" || arg === "disable") {
64
+ if (subcommand !== null) {
65
+ throw validationError(
66
+ `session-sync accepts exactly one subcommand; got "${subcommand}" and "${arg}"`,
67
+ );
68
+ }
69
+ subcommand = arg;
70
+ return 1;
71
+ }
72
+ return false;
73
+ },
74
+ });
75
+
76
+ if (!subcommand) {
77
+ throw validationError(
78
+ "session-sync requires a subcommand (enable or disable).",
79
+ { hint: "Usage: skillrepo session-sync <enable|disable> [--global] [--json]" },
80
+ );
81
+ }
82
+
83
+ // ── Dispatch ────────────────────────────────────────────────────
84
+ if (subcommand === "enable") {
85
+ const result = mergeSessionHook({ global: flags.global });
86
+ if (flags.json) {
87
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
88
+ return;
89
+ }
90
+ printEnableResult(result, stdout);
91
+ return;
92
+ }
93
+
94
+ if (subcommand === "disable") {
95
+ const result = removeSessionHook({ global: flags.global });
96
+ if (flags.json) {
97
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
98
+ return;
99
+ }
100
+ printDisableResult(result, stdout);
101
+ return;
102
+ }
103
+
104
+ // Unreachable — the acceptPositional callback only accepts the
105
+ // two subcommands. Defensive throw so a future addition with a
106
+ // typo'd branch surfaces immediately.
107
+ throw validationError(`session-sync: unknown subcommand "${subcommand}"`);
108
+ }
109
+
110
+ function printEnableResult(result, out) {
111
+ if (result.action === "installed") {
112
+ out.write(`\n ✓ SessionStart hook installed (${result.path})\n\n`);
113
+ return;
114
+ }
115
+ if (result.action === "updated") {
116
+ out.write(`\n ✓ SessionStart hook updated (${result.path})\n\n`);
117
+ return;
118
+ }
119
+ if (result.action === "unchanged") {
120
+ out.write(`\n ✓ SessionStart hook already installed (${result.path})\n\n`);
121
+ return;
122
+ }
123
+ // "skipped" — reason is set
124
+ out.write(`\n ⚠ Could not enable session sync: ${result.reason}\n\n`);
125
+ }
126
+
127
+ function printDisableResult(result, out) {
128
+ if (result.action === "removed") {
129
+ out.write(`\n ✓ SessionStart hook removed (${result.path})\n\n`);
130
+ return;
131
+ }
132
+ if (result.action === "unchanged") {
133
+ out.write(
134
+ `\n ✓ SessionStart hook was not installed (${result.path}) — nothing to do.\n\n`,
135
+ );
136
+ return;
137
+ }
138
+ // "skipped" with an error — the settings file exists but couldn't
139
+ // be parsed. Both reviewers (round 2) flagged the observability
140
+ // gap: without this branch, a corrupt file looked identical to
141
+ // "file doesn't exist" in human output, and users couldn't tell
142
+ // the difference. The --json path already surfaces result.error
143
+ // via its full serialize, so only human mode was affected.
144
+ if (result.action === "skipped" && result.error) {
145
+ out.write(`\n ⚠ ${result.error}\n\n`);
146
+ return;
147
+ }
148
+ // "skipped" without an error — file genuinely doesn't exist.
149
+ out.write(
150
+ `\n ✓ No settings file at ${result.path} — session sync is not enabled.\n\n`,
151
+ );
152
+ }