skillrepo 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +72 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +132 -14
  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 +265 -0
  10. package/src/lib/fs-utils.mjs +83 -1
  11. package/src/lib/mergers/session-hook.mjs +298 -0
  12. package/src/lib/paths.mjs +21 -0
  13. package/src/lib/removers/claude-mcp.mjs +67 -0
  14. package/src/lib/removers/cursor-mcp.mjs +60 -0
  15. package/src/lib/removers/env-local.mjs +55 -0
  16. package/src/lib/removers/gitignore.mjs +108 -0
  17. package/src/lib/removers/settings.mjs +183 -0
  18. package/src/lib/removers/vscode-mcp.mjs +87 -0
  19. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  20. package/src/test/commands/init.test.mjs +211 -0
  21. package/src/test/commands/session-sync.test.mjs +350 -0
  22. package/src/test/commands/uninstall.test.mjs +768 -0
  23. package/src/test/commands/update.test.mjs +158 -0
  24. package/src/test/lib/artifact-registry.test.mjs +268 -0
  25. package/src/test/mergers/session-hook.test.mjs +745 -0
  26. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  27. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  28. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  29. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  30. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  31. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  32. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -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,70 @@ 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
+ **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.
146
+
147
+ **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
+
149
+ Flags:
150
+
151
+ - `--global` — operates on `~/.claude/settings.local.json` so the hook fires in every Claude Code session across all projects on your machine.
152
+ - `--json` — emit structured JSON with `action`, `path`, and `command` fields for scripting.
153
+
154
+ ### `uninstall` — remove SkillRepo from a project
155
+
156
+ ```sh
157
+ skillrepo uninstall [--dry-run] [--yes] [--global] [--json]
158
+ ```
159
+
160
+ Surgically removes every SkillRepo artifact from the current project:
161
+
162
+ - `mcpServers.skillrepo` from `.mcp.json`, `.cursor/mcp.json`,
163
+ and `.vscode/mcp.json` (plus the matching `inputs` prompt in the
164
+ VS Code config)
165
+ - `SKILLREPO_ACCESS_KEY=...` lines from `.env.local`
166
+ - The SkillRepo section of `.gitignore`
167
+ - The SkillRepo `SessionStart` hook from `.claude/settings.local.json`
168
+ (if present)
169
+ - The `.claude/skills/` directory
170
+
171
+ Non-SkillRepo entries in shared files are preserved. Runs offline — no
172
+ server call required, so a revoked or missing access key is not a
173
+ problem. Interactive by default: the command prints a full list of what
174
+ will be removed and prompts for confirmation before touching anything.
175
+
176
+ With `--global`, also removes:
177
+
178
+ - `mcpServers.skillrepo` from `~/.codeium/windsurf/mcp_config.json`
179
+ - The `~/.claude/skills/` global skill cache
180
+ - The `~/.claude/skillrepo/` directory (stored credentials + sync cache)
181
+
182
+ Flags:
183
+
184
+ - `--dry-run` / `-n` — print what would be removed and exit without
185
+ touching any file.
186
+ - `--yes` / `-y` — skip the confirmation prompt.
187
+ - `--global` — also remove user-global state. By default the command
188
+ leaves your credential and other projects' integrations untouched.
189
+ - `--json` — emit structured JSON instead of human output. The summary
190
+ includes `removed[]` and `errors[]` arrays suitable for scripting.
191
+
192
+ The command is idempotent — a second run with nothing left to remove
193
+ exits 0 and reports "Nothing to remove." If any artifact fails to
194
+ remove (e.g. a file is read-only), the command continues processing
195
+ the others, surfaces every error at the end, and exits with code 3
196
+ (disk error).
197
+
132
198
  ## Configuration
133
199
 
134
200
  ### 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.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  *
@@ -47,6 +48,7 @@ import { mergeMcpForVendors, printManualMcpInstructions } from "../lib/mcp-merge
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 {
@@ -457,6 +554,17 @@ export async function runInit(argv, io = {}) {
457
554
  skipped: skipped.map((r) => r.path),
458
555
  failed: failed.map((r) => ({ path: r.path, reason: r.reason })),
459
556
  },
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)
564
+ sessionSync: {
565
+ action: sessionSyncAction,
566
+ path: sessionSyncPath,
567
+ },
460
568
  // Sync block always shows the counts (zeroed on failure)
461
569
  // and adds `failureReason` when the first sync blew up —
462
570
  // downstream scripts can `.failureReason != null` to
@@ -486,10 +594,12 @@ export async function runInit(argv, io = {}) {
486
594
  function parseInitFlags(argv) {
487
595
  // resolveFlags handles --key/--url/--global/--ide/--json and
488
596
  // 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.
597
+ // intercept --yes, --force, and --no-session-sync as "positional-
598
+ // shaped" flags via the callback so we don't need to pre-filter
599
+ // argv.
491
600
  let yes = false;
492
601
  let force = false;
602
+ let noSessionSync = false;
493
603
 
494
604
  const flags = resolveFlags(argv, {
495
605
  requireAuth: false, // init MAY prompt for a key, so don't hard-fail
@@ -510,9 +620,17 @@ function parseInitFlags(argv) {
510
620
  force = true;
511
621
  return 1;
512
622
  }
623
+ if (arg === "--no-session-sync") {
624
+ // #884: explicit opt-out for BOTH interactive and --yes
625
+ // modes. CI scripts that bootstrap a project without ever
626
+ // starting a Claude Code session pass this to skip the hook
627
+ // installation entirely.
628
+ noSessionSync = true;
629
+ return 1;
630
+ }
513
631
  return false; // anything else is unknown
514
632
  },
515
633
  });
516
634
 
517
- return { flags, yes, force };
635
+ return { flags, yes, force, noSessionSync };
518
636
  }
@@ -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
+ }