skillrepo 3.1.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 (33) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/commands/init.mjs +52 -5
  4. package/src/lib/artifact-registry.mjs +43 -3
  5. package/src/lib/cli-config.mjs +78 -0
  6. package/src/lib/config.mjs +6 -3
  7. package/src/lib/file-write.mjs +8 -3
  8. package/src/lib/fs-utils.mjs +9 -10
  9. package/src/lib/mergers/session-hook.mjs +99 -19
  10. package/src/lib/platform.mjs +124 -0
  11. package/src/lib/sync.mjs +26 -0
  12. package/src/test/commands/add.test.mjs +10 -4
  13. package/src/test/commands/get.test.mjs +10 -4
  14. package/src/test/commands/init.test.mjs +228 -15
  15. package/src/test/commands/list.test.mjs +10 -4
  16. package/src/test/commands/remove.test.mjs +10 -4
  17. package/src/test/commands/search.test.mjs +10 -4
  18. package/src/test/commands/session-sync.test.mjs +25 -23
  19. package/src/test/commands/uninstall.test.mjs +20 -14
  20. package/src/test/commands/update.test.mjs +10 -4
  21. package/src/test/helpers/sandbox-home.mjs +161 -0
  22. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  23. package/src/test/integration/file-write.integration.test.mjs +10 -4
  24. package/src/test/lib/cli-config.test.mjs +126 -5
  25. package/src/test/lib/config.test.mjs +10 -4
  26. package/src/test/lib/file-write.test.mjs +24 -10
  27. package/src/test/lib/mcp-merge.test.mjs +10 -4
  28. package/src/test/lib/paths.test.mjs +10 -4
  29. package/src/test/lib/platform.test.mjs +135 -0
  30. package/src/test/lib/sync.test.mjs +20 -4
  31. package/src/test/mergers/session-hook.test.mjs +441 -11
  32. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  33. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Platform conventions — single source of truth for OS-specific
3
+ * differences the CLI has to honor.
4
+ *
5
+ * The CLI runs on POSIX systems (macOS, Linux) and Windows. Most
6
+ * code paths are platform-neutral via Node built-ins (path.join,
7
+ * os.homedir, fs.rmSync, etc.) — but a handful of surfaces have
8
+ * real platform differences that can't be abstracted away at the
9
+ * Node level:
10
+ *
11
+ * 1. **Binary-locator command**. POSIX provides `which`; Windows
12
+ * provides `where.exe`. `execFileSync` doesn't spawn a shell,
13
+ * so the literal name must exist on disk.
14
+ *
15
+ * 2. **Hook shell backstop suffix**. The SessionStart hook command
16
+ * relies on a shell-level fallback (`|| true`) to guarantee
17
+ * exit 0 even if the binary vanishes. POSIX shells support it;
18
+ * cmd.exe doesn't know the `true` builtin and would emit a
19
+ * confusing error. The `--session-hook` flag's exit-0 contract
20
+ * inside the Node process is the primary defense regardless of
21
+ * platform; the shell backstop is belt-and-suspenders that we
22
+ * lose on Windows.
23
+ *
24
+ * 3. **POSIX file permissions**. `chmodSync(0o600)` silently
25
+ * succeeds on Windows but doesn't produce the intended effect —
26
+ * Windows's ACL model doesn't map to the Unix mode bits. Any
27
+ * call meant to restrict permissions on credential files must
28
+ * be guarded so Windows users aren't misled into thinking their
29
+ * files are access-controlled when they aren't.
30
+ *
31
+ * 4. **Atomic directory replacement semantics**. POSIX's
32
+ * `renameSync` over an existing directory is atomic on the same
33
+ * filesystem — the swap is instantaneous from the perspective
34
+ * of any concurrent reader. Windows fails with EEXIST/EPERM if
35
+ * the target exists; the replacement must be done as a
36
+ * remove-then-rename sequence with a small window where the
37
+ * target is missing. Callers that write skills have to know
38
+ * which strategy applies so they can surface a meaningful
39
+ * recovery hint if the Windows path fails mid-sequence.
40
+ *
41
+ * This module exposes a single `platformConventions()` function that
42
+ * returns a frozen object with every platform-specific value the
43
+ * CLI needs. New platform-specific surfaces should be added here
44
+ * rather than spreading ad-hoc `platform() === "win32"` checks
45
+ * across the codebase. This is a convention, not an enforced rule —
46
+ * it's documentation plus a consumer pattern, not a linter.
47
+ *
48
+ * The `platform` parameter is an optional override for tests —
49
+ * production callers let it default to `os.platform()`.
50
+ */
51
+
52
+ import { platform as osPlatform } from "node:os";
53
+
54
+ /**
55
+ * @typedef {Object} PlatformConventions
56
+ * @property {"posix" | "windows"} family - High-level family name.
57
+ * @property {string} binaryLocator - Command used to resolve a
58
+ * binary's absolute path from PATH. `"which"` on POSIX,
59
+ * `"where"` on Windows.
60
+ * @property {string} hookShellSuffix - Suffix appended to hook
61
+ * commands to guarantee exit 0 at the shell level. `" || true"`
62
+ * on POSIX (appended to the base command), empty string on
63
+ * Windows (the `--session-hook` flag's exit-0 contract is
64
+ * the only defense).
65
+ * @property {boolean} supportsPosixPermissions - True when
66
+ * `chmodSync(mode)` produces the intended POSIX mode-bit
67
+ * effect. False on Windows, where the call nominally
68
+ * succeeds but doesn't restrict ACLs the way a 0600 bit
69
+ * would on Unix. Callers use this to skip chmod on
70
+ * Windows rather than leave misleading "perms applied"
71
+ * success paths that don't actually restrict access.
72
+ * @property {boolean} supportsAtomicDirectoryRename - True when
73
+ * `renameSync` over an existing directory is atomic.
74
+ * POSIX: true. Windows: false — callers must implement
75
+ * remove-then-rename, accepting the small non-atomic
76
+ * window where the target doesn't exist. The Windows
77
+ * code path MUST produce a recoverable failure state if
78
+ * the rename step fails (i.e. leave the `.tmp/` dir on
79
+ * disk so the user can rename it manually).
80
+ */
81
+
82
+ const POSIX = Object.freeze({
83
+ family: "posix",
84
+ binaryLocator: "which",
85
+ hookShellSuffix: " || true",
86
+ supportsPosixPermissions: true,
87
+ supportsAtomicDirectoryRename: true,
88
+ });
89
+
90
+ const WINDOWS = Object.freeze({
91
+ family: "windows",
92
+ binaryLocator: "where",
93
+ hookShellSuffix: "",
94
+ supportsPosixPermissions: false,
95
+ supportsAtomicDirectoryRename: false,
96
+ });
97
+
98
+ /**
99
+ * Return the platform-specific convention set for the current
100
+ * platform, or an override.
101
+ *
102
+ * @param {object} [options]
103
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
104
+ * Production callers should let this default to the real
105
+ * runtime platform.
106
+ * @returns {PlatformConventions}
107
+ */
108
+ export function platformConventions({ platform: platformOverride } = {}) {
109
+ const plat = platformOverride ?? osPlatform();
110
+ return plat === "win32" ? WINDOWS : POSIX;
111
+ }
112
+
113
+ /**
114
+ * True if the current (or overridden) platform is Windows.
115
+ * Convenience wrapper — prefer `platformConventions().family ===
116
+ * "windows"` in call sites that already hold a conventions object.
117
+ *
118
+ * @param {object} [options]
119
+ * @param {NodeJS.Platform} [options.platform]
120
+ * @returns {boolean}
121
+ */
122
+ export function isWindows({ platform: platformOverride } = {}) {
123
+ return platformConventions({ platform: platformOverride }).family === "windows";
124
+ }
package/src/lib/sync.mjs CHANGED
@@ -27,6 +27,22 @@
27
27
  * @property {number} updated - Skills overwritten on disk
28
28
  * @property {number} removed - Tombstones applied
29
29
  * @property {boolean} notModified - True if 304 short-circuit fired
30
+ * @property {boolean | null} fullSync - True if this was a full (non-delta)
31
+ * sync — i.e. no prior `.last-sync` state
32
+ * existed so no `since` was sent. False if
33
+ * this was a delta sync. `null` only ever
34
+ * appears in SYNTHESIZED failure summaries
35
+ * (e.g. `init.mjs` when runSync threw) to
36
+ * mark the state as genuinely unknown —
37
+ * consumers should never see `null` from
38
+ * runSync itself. A full sync returning
39
+ * zero skills genuinely means "empty
40
+ * library"; a delta sync returning zero
41
+ * means "nothing changed since last sync".
42
+ * Consumers must distinguish these to
43
+ * render accurate user messages (see
44
+ * init.mjs step 7).
45
+ * @property {string} [syncedAt]
30
46
  * @property {string} syncedAt - ISO timestamp from the server response
31
47
  *
32
48
  * @typedef {Object} SyncStateFile
@@ -182,6 +198,14 @@ export async function runSync(options) {
182
198
  if (lastSync?.etag) opts.ifNoneMatch = lastSync.etag;
183
199
  if (lastSync?.syncedAt) opts.since = lastSync.syncedAt;
184
200
 
201
+ // Track whether this is a full or delta sync BEFORE the network
202
+ // call, for the returned summary's `fullSync` field. A "full" sync
203
+ // is one where no `since` was sent — which is exactly when no
204
+ // prior last-sync state existed. The distinction matters to
205
+ // consumers (init.mjs) that need to tell "empty library" from
206
+ // "nothing changed since last sync" in the zero-counters case.
207
+ const fullSync = !lastSync?.syncedAt;
208
+
185
209
  const result = await getLibrary(serverUrl, apiKey, opts);
186
210
 
187
211
  // Step 4: 304 short-circuit
@@ -191,6 +215,7 @@ export async function runSync(options) {
191
215
  updated: 0,
192
216
  removed: 0,
193
217
  notModified: true,
218
+ fullSync,
194
219
  syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
195
220
  };
196
221
  }
@@ -201,6 +226,7 @@ export async function runSync(options) {
201
226
  updated: 0,
202
227
  removed: 0,
203
228
  notModified: false,
229
+ fullSync,
204
230
  syncedAt: result.syncedAt,
205
231
  };
206
232
 
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
13
  import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
14
14
  import { createMockServer } from "../e2e/mock-server.mjs";
15
15
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+ import {
17
+ captureHome,
18
+ setSandboxHome,
19
+ restoreHome,
20
+ } from "../helpers/sandbox-home.mjs";
16
21
 
17
22
  let sandbox;
18
23
  let server;
19
24
  let serverUrl;
20
25
  let originalCwd;
21
- let originalHome;
26
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
27
+ let originalHomeEnv;
22
28
  let stdout;
23
29
  const VALID_KEY = "sk_live_test";
24
30
 
@@ -46,9 +52,9 @@ async function setup() {
46
52
  mkdirSync(join(sandbox, "project"), { recursive: true });
47
53
  mkdirSync(join(sandbox, "home"), { recursive: true });
48
54
  originalCwd = process.cwd();
49
- originalHome = process.env.HOME;
55
+ originalHomeEnv = captureHome();
50
56
  process.chdir(join(sandbox, "project"));
51
- process.env.HOME = join(sandbox, "home");
57
+ setSandboxHome(join(sandbox, "home"));
52
58
  delete process.env.SKILLREPO_ACCESS_KEY;
53
59
 
54
60
  server = createMockServer({});
@@ -61,7 +67,7 @@ async function setup() {
61
67
  async function teardown() {
62
68
  if (server) await server.stop();
63
69
  process.chdir(originalCwd);
64
- process.env.HOME = originalHome;
70
+ restoreHome(originalHomeEnv);
65
71
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
72
  server = null;
67
73
  }
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
13
  import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
14
14
  import { createMockServer } from "../e2e/mock-server.mjs";
15
15
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+ import {
17
+ captureHome,
18
+ setSandboxHome,
19
+ restoreHome,
20
+ } from "../helpers/sandbox-home.mjs";
16
21
 
17
22
  let sandbox;
18
23
  let server;
19
24
  let serverUrl;
20
25
  let originalCwd;
21
- let originalHome;
26
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
27
+ let originalHomeEnv;
22
28
  let stdout;
23
29
  const VALID_KEY = "sk_live_test";
24
30
 
@@ -53,9 +59,9 @@ async function setup() {
53
59
  mkdirSync(join(sandbox, "project"), { recursive: true });
54
60
  mkdirSync(join(sandbox, "home"), { recursive: true });
55
61
  originalCwd = process.cwd();
56
- originalHome = process.env.HOME;
62
+ originalHomeEnv = captureHome();
57
63
  process.chdir(join(sandbox, "project"));
58
- process.env.HOME = join(sandbox, "home");
64
+ setSandboxHome(join(sandbox, "home"));
59
65
  delete process.env.SKILLREPO_ACCESS_KEY;
60
66
  delete process.env.SKILLREPO_URL;
61
67
 
@@ -69,7 +75,7 @@ async function setup() {
69
75
  async function teardown() {
70
76
  if (server) await server.stop();
71
77
  process.chdir(originalCwd);
72
- process.env.HOME = originalHome;
78
+ restoreHome(originalHomeEnv);
73
79
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
74
80
  server = null;
75
81
  }
@@ -20,12 +20,18 @@ import { readConfig } from "../../lib/config.mjs";
20
20
  import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
21
21
  import { createMockServer } from "../e2e/mock-server.mjs";
22
22
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
23
+ import {
24
+ captureHome,
25
+ setSandboxHome,
26
+ restoreHome,
27
+ } from "../helpers/sandbox-home.mjs";
23
28
 
24
29
  let sandbox;
25
30
  let server;
26
31
  let serverUrl;
27
32
  let originalCwd;
28
- let originalHome;
33
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
34
+ let originalHomeEnv;
29
35
  let stdout;
30
36
  let stderr;
31
37
  const VALID_KEY = "sk_live_init_test";
@@ -38,9 +44,9 @@ async function setup() {
38
44
  mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
39
45
  mkdirSync(join(sandbox, "home"), { recursive: true });
40
46
  originalCwd = process.cwd();
41
- originalHome = process.env.HOME;
47
+ originalHomeEnv = captureHome();
42
48
  process.chdir(join(sandbox, "project"));
43
- process.env.HOME = join(sandbox, "home");
49
+ setSandboxHome(join(sandbox, "home"));
44
50
  delete process.env.SKILLREPO_ACCESS_KEY;
45
51
  delete process.env.SKILLREPO_URL;
46
52
 
@@ -55,7 +61,7 @@ async function setup() {
55
61
  async function teardown() {
56
62
  if (server) await server.stop();
57
63
  process.chdir(originalCwd);
58
- process.env.HOME = originalHome;
64
+ restoreHome(originalHomeEnv);
59
65
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
60
66
  server = null;
61
67
  }
@@ -499,25 +505,29 @@ describe("runInit — stale-key handling", () => {
499
505
  // init tests verify the ORCHESTRATION: --yes path, --no-session-sync
500
506
  // opt-out, --json output shape, and the non-fatal disk-error path.
501
507
 
502
- import { chmodSync as _chmodSync } from "node:fs";
503
508
  import { SESSION_HOOK_FINGERPRINT as _FINGERPRINT } from "../../lib/artifact-registry.mjs";
509
+ import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
510
+
511
+ /** @type {ReturnType<typeof installShim> | undefined} */
512
+ let _shimHandle;
504
513
 
505
514
  async function setupWithShim() {
506
515
  await setup();
507
- // Drop a deterministic `skillrepo` shim into HOME/bin so
508
- // `which skillrepo` resolves to it rather than a possibly-missing
509
- // global install.
510
- const binDir = join(process.env.HOME, "bin");
511
- mkdirSync(binDir, { recursive: true });
512
- const shim = join(binDir, "skillrepo");
513
- writeFileSync(shim, "#!/bin/sh\nexit 0\n");
514
- _chmodSync(shim, 0o755);
515
- process.env.PATH = `${binDir}:${process.env.PATH}`;
516
+ // Drop a cross-platform `skillrepo` shim into HOME/bin so the
517
+ // CLI's binary resolver (which/where) finds it rather than
518
+ // depending on whether the dev/CI machine has a global install.
519
+ _shimHandle = installShim(process.env.HOME);
520
+ }
521
+
522
+ async function teardownWithShim() {
523
+ uninstallShim(_shimHandle);
524
+ _shimHandle = undefined;
525
+ await teardown();
516
526
  }
517
527
 
518
528
  describe("runInit — session sync (#884)", () => {
519
529
  beforeEach(setupWithShim);
520
- afterEach(teardown);
530
+ afterEach(teardownWithShim);
521
531
 
522
532
  it("--yes installs the hook at step 6 by default", async () => {
523
533
  // INTENT: the architect-designed default for --yes mode is
@@ -695,3 +705,206 @@ describe("runInit — session sync (#884)", () => {
695
705
  assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
696
706
  });
697
707
  });
708
+
709
+ // ── v3.1.1 patch fixes: init UX bugs surfaced by real-world npx use ──
710
+ //
711
+ // Real-user `npx skillrepo@latest init` session surfaced four bugs in
712
+ // the v3.1.0 init output. The tests below lock each fix as a
713
+ // behavioral contract:
714
+ //
715
+ // 1. "Next steps" hardcoded `skillrepo` even under npx — would fail
716
+ // with "command not found" for users without a global install.
717
+ // 2. Session-sync hook installed under npx with a cache-temporary
718
+ // path that breaks on cache eviction. (Covered in
719
+ // session-hook.test.mjs via isNpxInvocation guard test.)
720
+ // 3. Step-7 zero-delta message conflated "empty library" with
721
+ // "nothing changed since last sync" — lied to users who had
722
+ // synced skills but no server-side changes since.
723
+ // 4. `--verbose` rejected as Unknown argument by resolveFlags.
724
+ // (Covered in cli-config.test.mjs.)
725
+
726
+ describe("runInit — v3.1.1 zero-delta message (bug 3)", () => {
727
+ beforeEach(setup);
728
+ afterEach(teardown);
729
+
730
+ it("reports 'Library is up to date' when a delta sync returns zero changes", async () => {
731
+ // Reproduces the real-user bug: a user with an existing
732
+ // .last-sync from a prior v3.0.0 session runs `skillrepo init`
733
+ // again. Init's step 7 sends `since=<prior syncedAt>`. Server
734
+ // returns empty skills[] because nothing changed server-side.
735
+ // Counters (added/updated/removed) all zero.
736
+ //
737
+ // Before the fix: init printed "No skills in library yet"
738
+ // regardless of whether the library was empty or fully synced.
739
+ // After: init distinguishes via the new `fullSync` field in
740
+ // SyncSummary and prints "Library is up to date (no changes
741
+ // since last sync)" for the delta case.
742
+ //
743
+ // Pre-seed a .last-sync file simulating the prior sync state.
744
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
745
+ writeFileSync(
746
+ join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
747
+ JSON.stringify({
748
+ schemaVersion: 1,
749
+ etag: '"old-v300-format-etag"',
750
+ syncedAt: "2026-04-15T12:00:00.000Z",
751
+ }),
752
+ );
753
+
754
+ // Server returns empty skills[] (no changes since last sync).
755
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
756
+
757
+ await runInit(
758
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
759
+ { stdout, stderr },
760
+ );
761
+
762
+ const out = stdout.text();
763
+ assert.match(
764
+ out,
765
+ /Library is up to date/,
766
+ "zero-delta on delta sync must report 'up to date', not 'no skills in library yet'",
767
+ );
768
+ assert.doesNotMatch(
769
+ out,
770
+ /No skills in library yet/,
771
+ "the misleading 'no skills' message must NOT fire when .last-sync existed",
772
+ );
773
+ });
774
+
775
+ it("still reports 'No skills in library yet' on a true first-run full sync with zero skills", async () => {
776
+ // The other side of bug 3: when there's NO prior .last-sync and
777
+ // the server returns zero skills, the library IS genuinely
778
+ // empty. The message should still say so.
779
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
780
+
781
+ await runInit(
782
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
783
+ { stdout, stderr },
784
+ );
785
+
786
+ const out = stdout.text();
787
+ assert.match(out, /No skills in library yet/);
788
+ });
789
+
790
+ it("304 Not Modified reports 'Library is up to date' (neutral phrasing)", async () => {
791
+ // 304 path: server returned "nothing changed" at the HTTP
792
+ // level. Definitively up-to-date regardless of whether library
793
+ // is populated or empty.
794
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
795
+ writeFileSync(
796
+ join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
797
+ JSON.stringify({
798
+ schemaVersion: 1,
799
+ etag: '"match-me"',
800
+ syncedAt: "2026-04-15T12:00:00.000Z",
801
+ }),
802
+ );
803
+ server.setEtag('"match-me"');
804
+
805
+ await runInit(
806
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
807
+ { stdout, stderr },
808
+ );
809
+
810
+ assert.match(stdout.text(), /Library is up to date/);
811
+ });
812
+ });
813
+
814
+ describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
815
+ // Process-state isolation: the tests below depend on controlling
816
+ // isNpxInvocation()'s outputs. The test host's shell environment
817
+ // may have `_` set to some launching command (the enclosing `npm
818
+ // run check`, `node --test`, etc.). beforeEach force-clears the
819
+ // two isNpxInvocation signals (argv[1] and `_`) plus the
820
+ // now-unused `npm_command` — the latter is cleared defensively
821
+ // because older versions of this codebase treated it as an npx
822
+ // signal, and belt-and-suspenders cleanup costs nothing.
823
+ let originalArgv;
824
+ let originalNpmCommand;
825
+ let originalUnderscore;
826
+
827
+ beforeEach(async () => {
828
+ await setup();
829
+ originalArgv = process.argv;
830
+ originalNpmCommand = process.env.npm_command;
831
+ originalUnderscore = process.env._;
832
+ // Non-npx state:
833
+ process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
834
+ delete process.env.npm_command;
835
+ delete process.env._;
836
+ });
837
+
838
+ afterEach(async () => {
839
+ process.argv = originalArgv;
840
+ if (originalNpmCommand === undefined) delete process.env.npm_command;
841
+ else process.env.npm_command = originalNpmCommand;
842
+ if (originalUnderscore === undefined) delete process.env._;
843
+ else process.env._ = originalUnderscore;
844
+ await teardown();
845
+ });
846
+
847
+ it("shows bare `skillrepo` prefix when running from a stable install", async () => {
848
+ // With all npx signals cleared (beforeEach above), isNpxInvocation
849
+ // returns false and the bare prefix is used in the Next Steps
850
+ // block.
851
+ //
852
+ // NOTE ON REGEX: "npm install -g skillrepo" appears in TWO places
853
+ // that look alike but come from different code paths:
854
+ //
855
+ // 1. Step 6/7 session-sync skipped message (when the binary
856
+ // can't be resolved — in the test environment, `which
857
+ // skillrepo` doesn't find anything real).
858
+ // 2. Next Steps tip (only shown under npx).
859
+ //
860
+ // We want to assert only #2 is absent. Match the EXACT "Tip:"
861
+ // prefix to scope the assertion to Next Steps.
862
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
863
+ await runInit(
864
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
865
+ { stdout, stderr },
866
+ );
867
+
868
+ const out = stdout.text();
869
+ // The commands in Next Steps are bare, not prefixed.
870
+ assert.match(out, /^ +• skillrepo list/m);
871
+ assert.doesNotMatch(
872
+ out,
873
+ /npx skillrepo list/,
874
+ "bare install must NOT show the npx-prefixed hints",
875
+ );
876
+ // The "Tip: …" line appears only under npx.
877
+ assert.doesNotMatch(
878
+ out,
879
+ /Tip: `npm install -g skillrepo`/,
880
+ "bare install must NOT show the npx-mode global-install tip",
881
+ );
882
+ });
883
+
884
+ it("shows `npx skillrepo` prefix and global-install tip under npx invocation", async () => {
885
+ // Simulate npx by stuffing argv[1] with an _npx cache path.
886
+ // isNpxInvocation() returns true and the output changes shape.
887
+ process.argv = [
888
+ "/usr/local/bin/node",
889
+ "/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
890
+ ];
891
+
892
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
893
+ await runInit(
894
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
895
+ { stdout, stderr },
896
+ );
897
+
898
+ const out = stdout.text();
899
+ assert.match(
900
+ out,
901
+ /npx skillrepo list/,
902
+ "npx invocation must prefix commands with `npx`",
903
+ );
904
+ assert.match(
905
+ out,
906
+ /Tip: `npm install -g skillrepo`/,
907
+ "npx invocation must show the Next-Steps global-install tip",
908
+ );
909
+ });
910
+ });
@@ -12,12 +12,18 @@ import { runList } from "../../commands/list.mjs";
12
12
  import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
13
13
  import { createMockServer } from "../e2e/mock-server.mjs";
14
14
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
+ import {
16
+ captureHome,
17
+ setSandboxHome,
18
+ restoreHome,
19
+ } from "../helpers/sandbox-home.mjs";
15
20
 
16
21
  let sandbox;
17
22
  let server;
18
23
  let serverUrl;
19
24
  let originalCwd;
20
- let originalHome;
25
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
26
+ let originalHomeEnv;
21
27
  let stdout;
22
28
  const VALID_KEY = "sk_live_test";
23
29
 
@@ -37,9 +43,9 @@ async function setup() {
37
43
  mkdirSync(join(sandbox, "project"), { recursive: true });
38
44
  mkdirSync(join(sandbox, "home"), { recursive: true });
39
45
  originalCwd = process.cwd();
40
- originalHome = process.env.HOME;
46
+ originalHomeEnv = captureHome();
41
47
  process.chdir(join(sandbox, "project"));
42
- process.env.HOME = join(sandbox, "home");
48
+ setSandboxHome(join(sandbox, "home"));
43
49
  delete process.env.SKILLREPO_ACCESS_KEY;
44
50
 
45
51
  server = createMockServer({});
@@ -52,7 +58,7 @@ async function setup() {
52
58
  async function teardown() {
53
59
  if (server) await server.stop();
54
60
  process.chdir(originalCwd);
55
- process.env.HOME = originalHome;
61
+ restoreHome(originalHomeEnv);
56
62
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
57
63
  server = null;
58
64
  }
@@ -13,12 +13,18 @@ import { writeSkillDir, resolvePlacementDir } from "../../lib/file-write.mjs";
13
13
  import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
14
14
  import { createMockServer } from "../e2e/mock-server.mjs";
15
15
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+ import {
17
+ captureHome,
18
+ setSandboxHome,
19
+ restoreHome,
20
+ } from "../helpers/sandbox-home.mjs";
16
21
 
17
22
  let sandbox;
18
23
  let server;
19
24
  let serverUrl;
20
25
  let originalCwd;
21
- let originalHome;
26
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
27
+ let originalHomeEnv;
22
28
  let stdout;
23
29
  const VALID_KEY = "sk_live_test";
24
30
 
@@ -46,9 +52,9 @@ async function setup() {
46
52
  mkdirSync(join(sandbox, "project"), { recursive: true });
47
53
  mkdirSync(join(sandbox, "home"), { recursive: true });
48
54
  originalCwd = process.cwd();
49
- originalHome = process.env.HOME;
55
+ originalHomeEnv = captureHome();
50
56
  process.chdir(join(sandbox, "project"));
51
- process.env.HOME = join(sandbox, "home");
57
+ setSandboxHome(join(sandbox, "home"));
52
58
  delete process.env.SKILLREPO_ACCESS_KEY;
53
59
 
54
60
  server = createMockServer({});
@@ -61,7 +67,7 @@ async function setup() {
61
67
  async function teardown() {
62
68
  if (server) await server.stop();
63
69
  process.chdir(originalCwd);
64
- process.env.HOME = originalHome;
70
+ restoreHome(originalHomeEnv);
65
71
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
72
  server = null;
67
73
  }
@@ -12,12 +12,18 @@ import { runSearch } from "../../commands/search.mjs";
12
12
  import { CliError, EXIT_VALIDATION, EXIT_AUTH } from "../../lib/errors.mjs";
13
13
  import { createMockServer } from "../e2e/mock-server.mjs";
14
14
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
+ import {
16
+ captureHome,
17
+ setSandboxHome,
18
+ restoreHome,
19
+ } from "../helpers/sandbox-home.mjs";
15
20
 
16
21
  let sandbox;
17
22
  let server;
18
23
  let serverUrl;
19
24
  let originalCwd;
20
- let originalHome;
25
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
26
+ let originalHomeEnv;
21
27
  let stdout;
22
28
  let stderr;
23
29
  const VALID_KEY = "sk_live_test";
@@ -42,9 +48,9 @@ async function setup() {
42
48
  mkdirSync(join(sandbox, "project"), { recursive: true });
43
49
  mkdirSync(join(sandbox, "home"), { recursive: true });
44
50
  originalCwd = process.cwd();
45
- originalHome = process.env.HOME;
51
+ originalHomeEnv = captureHome();
46
52
  process.chdir(join(sandbox, "project"));
47
- process.env.HOME = join(sandbox, "home");
53
+ setSandboxHome(join(sandbox, "home"));
48
54
  delete process.env.SKILLREPO_ACCESS_KEY;
49
55
 
50
56
  server = createMockServer({});
@@ -58,7 +64,7 @@ async function setup() {
58
64
  async function teardown() {
59
65
  if (server) await server.stop();
60
66
  process.chdir(originalCwd);
61
- process.env.HOME = originalHome;
67
+ restoreHome(originalHomeEnv);
62
68
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
63
69
  server = null;
64
70
  }