skillrepo 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/src/commands/init-session-sync.mjs +307 -0
  4. package/src/commands/init.mjs +111 -101
  5. package/src/commands/session-sync-actions.mjs +92 -0
  6. package/src/lib/artifact-registry.mjs +43 -3
  7. package/src/lib/binary-locator.mjs +99 -0
  8. package/src/lib/cli-config.mjs +16 -3
  9. package/src/lib/cli-version.mjs +56 -0
  10. package/src/lib/config.mjs +6 -3
  11. package/src/lib/file-write.mjs +8 -3
  12. package/src/lib/fs-utils.mjs +9 -10
  13. package/src/lib/global-install.mjs +387 -0
  14. package/src/lib/mcp-merge.mjs +16 -5
  15. package/src/lib/mergers/session-hook.mjs +125 -33
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/sync.mjs +26 -0
  18. package/src/lib/transient-runners.mjs +204 -0
  19. package/src/test/commands/add.test.mjs +10 -4
  20. package/src/test/commands/get.test.mjs +10 -4
  21. package/src/test/commands/init.test.mjs +889 -15
  22. package/src/test/commands/list.test.mjs +10 -4
  23. package/src/test/commands/remove.test.mjs +10 -4
  24. package/src/test/commands/search.test.mjs +10 -4
  25. package/src/test/commands/session-sync-actions.test.mjs +74 -0
  26. package/src/test/commands/session-sync.test.mjs +25 -23
  27. package/src/test/commands/uninstall.test.mjs +20 -14
  28. package/src/test/commands/update.test.mjs +10 -4
  29. package/src/test/helpers/mock-spawn.mjs +121 -0
  30. package/src/test/helpers/sandbox-home.mjs +161 -0
  31. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  32. package/src/test/integration/file-write.integration.test.mjs +10 -4
  33. package/src/test/lib/cli-config.test.mjs +182 -4
  34. package/src/test/lib/cli-version.test.mjs +47 -0
  35. package/src/test/lib/config.test.mjs +10 -4
  36. package/src/test/lib/file-write.test.mjs +24 -10
  37. package/src/test/lib/global-install.test.mjs +424 -0
  38. package/src/test/lib/mcp-merge.test.mjs +13 -7
  39. package/src/test/lib/paths.test.mjs +10 -4
  40. package/src/test/lib/platform.test.mjs +135 -0
  41. package/src/test/lib/sync.test.mjs +20 -4
  42. package/src/test/lib/transient-runners.test.mjs +270 -0
  43. package/src/test/mergers/session-hook.test.mjs +722 -22
  44. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  45. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
@@ -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
+ );
@@ -88,8 +88,48 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
88
88
  /**
89
89
  * Substring that identifies a SessionStart hook command entry as
90
90
  * SkillRepo-owned. The #884 installer writes a hook whose `command`
91
- * field contains `skillrepo update --session-hook`; any entry whose
92
- * command contains this substring is removed by the uninstall path.
91
+ * field ends with `<binary-path> update --session-hook ...`; any
92
+ * entry whose command contains ` update --session-hook` (with the
93
+ * leading space) is removed by the uninstall path.
94
+ *
95
+ * The leading space is a lightweight word boundary — it requires
96
+ * that `update` is preceded by whitespace (i.e. it's an argv token
97
+ * after the binary path), not a suffix of a longer identifier like
98
+ * `toolupdate` or `postupdate`. Without the space, a hypothetical
99
+ * binary at `/usr/local/bin/myapp-update` invoked with
100
+ * `--session-hook` as `/usr/local/bin/myapp-update --session-hook`
101
+ * would NOT match (because the substring would be `-update
102
+ * --session-hook`, not ` update --session-hook`), whereas a naive
103
+ * `update --session-hook` fingerprint would have.
104
+ *
105
+ * The leading space does NOT eliminate all false-positive classes.
106
+ * A command like `brew update --session-hook` DOES match the
107
+ * fingerprint — the space between `brew` and `update` is exactly
108
+ * what we key on. The primary protection against real-world false
109
+ * positives is the specificity of the two-token combination
110
+ * `update --session-hook` itself: `--session-hook` is not a
111
+ * conventional flag name used by tools other than SkillRepo, so the
112
+ * chance of a coincidental match is astronomically low. The test
113
+ * at `session-hook.test.mjs` "the fingerprint is specific enough
114
+ * that innocuous user hooks do NOT match it" enumerates plausible
115
+ * user-hook commands and confirms none trip the predicate.
116
+ *
117
+ * The fingerprint is also deliberately platform-neutral. Earlier
118
+ * versions matched the longer `skillrepo update --session-hook`
119
+ * substring, but that pattern silently fails to match Windows hook
120
+ * commands because npm installs the CLI as a `.cmd` shim — the
121
+ * absolute path on Windows ends `...\skillrepo.cmd`, which puts the
122
+ * `.cmd` extension between `skillrepo` and `update` in the command
123
+ * string. The shorter ` update --session-hook` substring is present
124
+ * on both:
125
+ * POSIX: `/usr/local/bin/skillrepo update --session-hook 2>&1 || true`
126
+ * Windows: `C:\path\skillrepo.cmd update --session-hook 2>&1`
127
+ *
128
+ * Backward-compat: any v3.1.0 hook contains
129
+ * `skillrepo update --session-hook`, which is a strict superset of
130
+ * ` update --session-hook` (the space between `skillrepo` and `update`
131
+ * is the space we're matching). So upgrades still correctly identify
132
+ * and update the old entry in place.
93
133
  *
94
134
  * Exported so #884's installer can import and use the same constant —
95
135
  * this is the module boundary that makes #884 depend on #885 rather
@@ -97,7 +137,7 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
97
137
  * 5.3) notes the bidirectional-fingerprint requirement; centralizing
98
138
  * it here enforces it at the language level.
99
139
  */
100
- export const SESSION_HOOK_FINGERPRINT = "skillrepo update --session-hook";
140
+ export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
101
141
 
102
142
  // ── Artifact descriptors ────────────────────────────────────────────
103
143
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Cross-platform binary locator with optional transient-runner
3
+ * filtering (#894 / v3.1.2).
4
+ *
5
+ * Wraps `where` (Windows) / `which` (POSIX) to find a named binary
6
+ * on PATH and returns its absolute path. The two flag knobs
7
+ * `skipIfTransient` and `filterTransient` were extracted from the
8
+ * v3.1.2 first cleanup pass: previously two near-identical functions
9
+ * (`resolveSkillrepoBinary` in `mergers/session-hook.mjs`,
10
+ * `resolveGlobalBinary` in `lib/global-install.mjs`) duplicated this
11
+ * logic with the only differences being:
12
+ *
13
+ * - `resolveSkillrepoBinary` had an early-return guard against
14
+ * `isNpxInvocation()` (now `isTransientRunnerInvocation`) — the
15
+ * `skipIfTransient` flag captures that.
16
+ *
17
+ * - `resolveGlobalBinary` filtered `_npx`-cache results from the
18
+ * locator output — the `filterTransient` flag (powered by
19
+ * `isTransientCachePath` from `lib/transient-runners.mjs`)
20
+ * captures that and extends it to all package runners (pnpm
21
+ * dlx, yarn berry dlx, bunx).
22
+ *
23
+ * Both flags default to false so the function behaves like a plain
24
+ * `which`/`where` wrapper unless the caller opts into the extra
25
+ * semantics.
26
+ */
27
+
28
+ import { execFileSync } from "node:child_process";
29
+ import { isAbsolute } from "node:path";
30
+ import { platformConventions } from "./platform.mjs";
31
+ import {
32
+ isTransientCachePath,
33
+ isTransientRunnerInvocation,
34
+ } from "./transient-runners.mjs";
35
+
36
+ /**
37
+ * Resolve the absolute path of `binaryName` on PATH.
38
+ *
39
+ * @param {string} binaryName - The bare command name (e.g.
40
+ * `"skillrepo"`). Resolved via the platform's binary locator
41
+ * (`which` on POSIX, `where` on Windows).
42
+ * @param {object} [options]
43
+ * @param {boolean} [options.skipIfTransient=false] - When true,
44
+ * return `null` immediately if the current process is itself
45
+ * a transient-runner invocation. Used by callers that bake
46
+ * the resolved path into long-lived state (e.g. a SessionStart
47
+ * hook command) and must not bind to a transient cache path.
48
+ * @param {boolean} [options.filterTransient=false] - When true,
49
+ * ignore locator output lines that point inside a transient
50
+ * runner's cache directory. Used by callers that explicitly
51
+ * want a STABLE global install at a non-cache path.
52
+ * @param {NodeJS.Platform} [options.platform] - Override for tests.
53
+ * Production callers leave unset.
54
+ * @returns {string | null} The absolute path of the first matching
55
+ * non-filtered locator output line, or `null` if no match.
56
+ */
57
+ export function resolveBinaryOnPath(
58
+ binaryName,
59
+ { skipIfTransient = false, filterTransient = false, platform: platformOverride } = {},
60
+ ) {
61
+ if (skipIfTransient && isTransientRunnerInvocation()) {
62
+ return null;
63
+ }
64
+
65
+ const conv = platformConventions({ platform: platformOverride });
66
+
67
+ let raw;
68
+ try {
69
+ // 3-second cap — `which`/`where` typically return in
70
+ // milliseconds, but a pathological PATH (network filesystem,
71
+ // hung shell alias) could otherwise stall the whole CLI.
72
+ raw = execFileSync(conv.binaryLocator, [binaryName], {
73
+ encoding: "utf-8",
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ timeout: 3000,
76
+ });
77
+ } catch {
78
+ // Locator exits non-zero when the binary isn't on PATH, OR
79
+ // throws ENOENT when the locator itself doesn't exist (rare —
80
+ // a Windows install missing `where.exe`, or a minimal POSIX
81
+ // image without `which`). Both collapse to "binary not
82
+ // resolvable" from the caller's perspective.
83
+ return null;
84
+ }
85
+
86
+ // Windows `where.exe` returns one match per line; POSIX `which`
87
+ // returns a single line. Take the first match that survives the
88
+ // absolute-path and (optional) transient-cache filters.
89
+ const lines = raw
90
+ .split(/\r?\n/)
91
+ .map((s) => s.trim())
92
+ .filter(Boolean);
93
+ for (const line of lines) {
94
+ if (!isAbsolute(line)) continue;
95
+ if (filterTransient && isTransientCachePath(line)) continue;
96
+ return line;
97
+ }
98
+ return null;
99
+ }
@@ -8,9 +8,13 @@
8
8
  * 4. Hard-error with an actionable hint pointing at `init` if no
9
9
  * key is configured
10
10
  *
11
- * Centralizing this here keeps the four command modules thin and
12
- * means a future change to credential resolution (e.g., adding a
13
- * keychain backend) is a single edit instead of four.
11
+ * Centralizing this here keeps the command modules thin and means
12
+ * a future change to credential resolution (e.g., adding a keychain
13
+ * backend) is a single edit instead of N.
14
+ *
15
+ * Process-environment helpers for transient package-runner detection
16
+ * (npx, pnpm dlx, yarn dlx, bunx) live in `lib/transient-runners.mjs`
17
+ * and `lib/binary-locator.mjs` — import from there directly.
14
18
  */
15
19
 
16
20
  import { existsSync, readFileSync } from "node:fs";
@@ -85,6 +89,15 @@ export function resolveFlags(argv, opts = {}) {
85
89
  } else if (arg === "--help" || arg === "-h") {
86
90
  // Dispatcher should have intercepted this. Defensive no-op.
87
91
  continue;
92
+ } else if (arg === "--verbose") {
93
+ // Global flag set by the dispatcher into SKILLREPO_VERBOSE=1
94
+ // so http.mjs's retry logger can honor it. It's a first-class
95
+ // flag, not an unknown arg — accept it silently in every
96
+ // command that passes through resolveFlags. Before this
97
+ // branch existed, any command that consumed argv via
98
+ // resolveFlags rejected `--verbose` with "Unknown argument",
99
+ // breaking the flag documented in the top-level --help.
100
+ continue;
88
101
  } else {
89
102
  // Allow the caller to consume a positional arg before we treat
90
103
  // it as unknown. This is how `get @owner/name` and
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Read the running CLI's own version from the package.json that ships
3
+ * with the tarball.
4
+ *
5
+ * Why a dedicated module
6
+ * ----------------------
7
+ * v3.1.2's init auto-install feature needs to run
8
+ * `npm install -g skillrepo@<version>` with the SAME version as the
9
+ * CLI currently running init. Pinning is what keeps the fresh global
10
+ * install in lockstep with the npx-cache copy that just executed init,
11
+ * so there's no silent version drift between the two.
12
+ *
13
+ * `process.env.npm_package_version` is NOT reliable here — it's only
14
+ * set when npm itself spawns the process (e.g. `npm run`), not under
15
+ * a plain `npx skillrepo ...` or `node ./bin/skillrepo.mjs` run. So
16
+ * we read the package.json off disk.
17
+ *
18
+ * The read is relative to `import.meta.url`, which resolves to this
19
+ * module's own file URL. From `src/lib/cli-version.mjs`, two levels
20
+ * up is the package root — the `"files"` array in package.json
21
+ * includes `bin/` and `src/` alongside the package.json itself, so
22
+ * the file is always present in the shipped tarball.
23
+ *
24
+ * A failed read is a programming error (missing or malformed
25
+ * package.json in a shipped tarball means the build pipeline broke
26
+ * invariants). The function throws rather than soft-handling; there
27
+ * is no meaningful user recovery path.
28
+ */
29
+
30
+ import { readFileSync } from "node:fs";
31
+
32
+ /**
33
+ * Return the CLI's own semver version as a string (e.g. "3.1.2").
34
+ *
35
+ * @returns {string}
36
+ * @throws {Error} if the package.json cannot be read or parsed, or
37
+ * if the `version` field is missing or not a string.
38
+ */
39
+ export function getCliVersion() {
40
+ // Resolve `../../package.json` relative to THIS file. This module
41
+ // lives at `packages/cli/src/lib/cli-version.mjs`, so two `..`
42
+ // segments land at the package root. Using `import.meta.url` means
43
+ // the resolution is invariant under tarball/symlink layout — it
44
+ // always points to where THIS source file actually lives on disk
45
+ // at runtime.
46
+ const pkgUrl = new URL("../../package.json", import.meta.url);
47
+ const raw = readFileSync(pkgUrl, "utf-8");
48
+ const pkg = JSON.parse(raw);
49
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
50
+ throw new Error(
51
+ "cli-version: package.json has no valid `version` string. " +
52
+ "This indicates a broken build/publish pipeline.",
53
+ );
54
+ }
55
+ return pkg.version;
56
+ }
@@ -43,10 +43,10 @@ import {
43
43
  unlinkSync,
44
44
  } from "node:fs";
45
45
  import { dirname } from "node:path";
46
- import { platform } from "node:os";
47
46
 
48
47
  import { globalConfigPath } from "./paths.mjs";
49
48
  import { diskError, validationError } from "./errors.mjs";
49
+ import { platformConventions } from "./platform.mjs";
50
50
 
51
51
  /**
52
52
  * Current schema version. Bump this on any structural change.
@@ -187,8 +187,11 @@ export function writeConfig(config) {
187
187
 
188
188
  // chmod the temp file before renaming so the destination never
189
189
  // exists with world-readable perms (which would be a brief
190
- // credential leak window on a shared system).
191
- if (platform() !== "win32") {
190
+ // credential leak window on a shared system). Windows callers
191
+ // route through platformConventions().supportsPosixPermissions
192
+ // see platform.mjs for why we skip chmod there instead of pretend-
193
+ // applying it.
194
+ if (platformConventions().supportsPosixPermissions) {
192
195
  try {
193
196
  chmodSync(tmpPath, 0o600);
194
197
  } catch {
@@ -50,7 +50,6 @@ import {
50
50
  statSync,
51
51
  } from "node:fs";
52
52
  import { dirname, join, isAbsolute, relative } from "node:path";
53
- import { platform } from "node:os";
54
53
 
55
54
  import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
56
55
  import {
@@ -63,6 +62,7 @@ import {
63
62
  gitignorePath,
64
63
  } from "./paths.mjs";
65
64
  import { CliError, validationError, diskError } from "./errors.mjs";
65
+ import { platformConventions } from "./platform.mjs";
66
66
 
67
67
  // ── Constants (mirror the server-side validators in src/lib/skills/) ────
68
68
 
@@ -562,8 +562,13 @@ function writeSkillToDir(skill, targetDir) {
562
562
  }
563
563
  }
564
564
 
565
- // 2 + 3 + 4: rename dance (POSIX atomic on same filesystem; best-effort on Windows)
566
- if (platform() === "win32") {
565
+ // 2 + 3 + 4: rename dance. POSIX is atomic on the same filesystem;
566
+ // Windows has to do remove-then-rename because renameSync fails on
567
+ // existing directory targets. The split is named via
568
+ // platformConventions().supportsAtomicDirectoryRename so the intent
569
+ // reads as a capability check, not a platform check. See
570
+ // platform.mjs for the rationale.
571
+ if (!platformConventions().supportsAtomicDirectoryRename) {
567
572
  // Windows: rename fails on existing destinations and locked files,
568
573
  // so we fall back to remove-then-rename. There is a window where
569
574
  // the live target is gone but the rename has not yet completed.
@@ -14,6 +14,7 @@ import {
14
14
  unlinkSync,
15
15
  } from "node:fs";
16
16
  import { dirname } from "node:path";
17
+ import { platformConventions } from "./platform.mjs";
17
18
 
18
19
  /**
19
20
  * Read a file as UTF-8, returning null if it doesn't exist.
@@ -44,15 +45,6 @@ export function writeFileSafe(filePath, content) {
44
45
  writeFileSync(filePath, content, "utf-8");
45
46
  }
46
47
 
47
- /**
48
- * Write a file and mark it executable (0o755).
49
- * Used for the Cursor session hook which is invoked directly via shebang.
50
- */
51
- export function writeExecutable(filePath, content) {
52
- writeFileSafe(filePath, content);
53
- chmodSync(filePath, 0o755);
54
- }
55
-
56
48
  /**
57
49
  * Check if a path exists (file or directory).
58
50
  */
@@ -105,7 +97,7 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
105
97
  throw new Error(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
106
98
  }
107
99
 
108
- if (mode !== undefined && process.platform !== "win32") {
100
+ if (mode !== undefined && platformConventions().supportsPosixPermissions) {
109
101
  try {
110
102
  chmodSync(tmpPath, mode);
111
103
  } catch {
@@ -115,6 +107,13 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
115
107
  // file after the write.
116
108
  }
117
109
  }
110
+ // On Windows we deliberately skip chmod entirely. Node lets the call
111
+ // succeed on Windows but the mode bits don't map to anything the
112
+ // ACL layer enforces, so a "success" return would mislead the caller
113
+ // into thinking the credential file is access-restricted when it
114
+ // isn't. Windows users needing per-user protection should rely on
115
+ // %APPDATA%'s inherited ACLs (which default to the current user) or
116
+ // apply DACL restrictions at the OS level — outside this CLI's scope.
118
117
 
119
118
  try {
120
119
  renameSync(tmpPath, filePath);