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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/init.mjs +52 -5
- package/src/lib/artifact-registry.mjs +43 -3
- package/src/lib/cli-config.mjs +78 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +9 -10
- package/src/lib/mergers/session-hook.mjs +99 -19
- package/src/lib/platform.mjs +124 -0
- package/src/lib/sync.mjs +26 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +228 -15
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync.test.mjs +25 -23
- package/src/test/commands/uninstall.test.mjs +20 -14
- package/src/test/commands/update.test.mjs +10 -4
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/cli-config.test.mjs +126 -5
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/mcp-merge.test.mjs +10 -4
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/mergers/session-hook.test.mjs +441 -11
- package/src/test/mergers/uninstall-settings.test.mjs +12 -1
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
package/README.md
CHANGED
|
@@ -142,7 +142,9 @@ Installs (or removes) a Claude Code [SessionStart hook](https://docs.claude.com/
|
|
|
142
142
|
|
|
143
143
|
By default `skillrepo init` prompts you to install this hook. If you said no (or passed `--no-session-sync`), run `session-sync enable` later to turn it on.
|
|
144
144
|
|
|
145
|
-
**
|
|
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.
|
|
146
148
|
|
|
147
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.
|
|
148
150
|
|
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
import { validateAccessKey } from "../lib/http.mjs";
|
|
44
44
|
import { detectIdes, formatDetectedIdes } from "../lib/detect-ides.mjs";
|
|
45
45
|
import { readConfig, writeConfig } from "../lib/config.mjs";
|
|
46
|
-
import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
|
|
46
|
+
import { resolveFlags, effectiveVendors, isNpxInvocation } from "../lib/cli-config.mjs";
|
|
47
47
|
import { mergeMcpForVendors, printManualMcpInstructions } from "../lib/mcp-merge.mjs";
|
|
48
48
|
import { runSync } from "../lib/sync.mjs";
|
|
49
49
|
import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
|
|
@@ -519,16 +519,46 @@ export async function runInit(argv, io = {}) {
|
|
|
519
519
|
updated: 0,
|
|
520
520
|
removed: 0,
|
|
521
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,
|
|
522
533
|
syncedAt: new Date().toISOString(),
|
|
523
534
|
};
|
|
524
535
|
}
|
|
525
536
|
|
|
537
|
+
const zeroDeltas =
|
|
538
|
+
syncSummary.added + syncSummary.updated + syncSummary.removed === 0;
|
|
539
|
+
|
|
526
540
|
if (syncFailedReason) {
|
|
527
541
|
// The warning already printed; the step-summary success line
|
|
528
542
|
// would be misleading, so we skip it. Any helpful "next steps"
|
|
529
543
|
// is in the final `SkillRepo is ready` block.
|
|
530
|
-
} else if (syncSummary.notModified
|
|
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.
|
|
531
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).");
|
|
532
562
|
} else {
|
|
533
563
|
p.success(
|
|
534
564
|
`${syncSummary.added} added, ${syncSummary.updated} updated, ${syncSummary.removed} removed`,
|
|
@@ -581,10 +611,27 @@ export async function runInit(argv, io = {}) {
|
|
|
581
611
|
}
|
|
582
612
|
|
|
583
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";
|
|
584
621
|
stdout.write(" Next steps:\n");
|
|
585
|
-
stdout.write(
|
|
586
|
-
stdout.write(
|
|
587
|
-
stdout.write(
|
|
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");
|
|
588
635
|
}
|
|
589
636
|
|
|
590
637
|
/**
|
|
@@ -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
|
|
92
|
-
* command contains
|
|
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 = "
|
|
140
|
+
export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
|
|
101
141
|
|
|
102
142
|
// ── Artifact descriptors ────────────────────────────────────────────
|
|
103
143
|
|
package/src/lib/cli-config.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared credential + flag resolution for command modules.
|
|
3
3
|
*
|
|
4
|
+
* Also houses process-environment helpers that multiple command
|
|
5
|
+
* modules need — specifically `isNpxInvocation()` which several
|
|
6
|
+
* surfaces use to decide whether the user has a stable global
|
|
7
|
+
* install or is running a transient npx download.
|
|
8
|
+
*
|
|
4
9
|
* Every command needs to:
|
|
5
10
|
* 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
|
|
6
11
|
* 2. Fall back to ~/.claude/skillrepo/config.json
|
|
@@ -21,6 +26,70 @@ import { authError, validationError } from "./errors.mjs";
|
|
|
21
26
|
const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
|
|
22
27
|
const VENDOR_ALIASES = { claude: "claudeCode" };
|
|
23
28
|
|
|
29
|
+
/**
|
|
30
|
+
* True when the current process was launched via `npx skillrepo ...`
|
|
31
|
+
* rather than from a stable global install.
|
|
32
|
+
*
|
|
33
|
+
* Why this matters:
|
|
34
|
+
*
|
|
35
|
+
* - `npx skillrepo init` downloads the package into `~/.npm/_npx/<hash>/`
|
|
36
|
+
* and exposes its `.bin/skillrepo` on PATH for the subprocess only.
|
|
37
|
+
* `execFileSync("which", ["skillrepo"])` DOES find that path, but it
|
|
38
|
+
* is a transient cache location. npm eviction, a version bump, or
|
|
39
|
+
* `npm cache clean` later invalidates the absolute path, so any
|
|
40
|
+
* on-disk reference to it (e.g. a SessionStart hook command baked
|
|
41
|
+
* in at install time) silently breaks.
|
|
42
|
+
*
|
|
43
|
+
* - The architect design for #884 explicitly specified that npx
|
|
44
|
+
* users should skip the session-sync step with a "requires a global
|
|
45
|
+
* install" warning. The `which`-based resolver in
|
|
46
|
+
* `mergers/session-hook.mjs` alone is too permissive — it finds the
|
|
47
|
+
* npx cache path and treats it as stable. This helper closes that
|
|
48
|
+
* gap by detecting npx unambiguously.
|
|
49
|
+
*
|
|
50
|
+
* - `init`'s "Next steps" output also needs to know: under npx, the
|
|
51
|
+
* right hint is `npx skillrepo list` (or "install globally first"),
|
|
52
|
+
* not bare `skillrepo list` (which would fail for the user).
|
|
53
|
+
*
|
|
54
|
+
* Detection uses two signals, either one sufficient:
|
|
55
|
+
*
|
|
56
|
+
* 1. `process.argv[1]` contains `/_npx/` (or Windows `\_npx\`) —
|
|
57
|
+
* the primary signal. npx-launched scripts literally live inside
|
|
58
|
+
* `~/.npm/_npx/<hash>/node_modules/.bin/...` so the executable
|
|
59
|
+
* path itself names the cache directory. Highest reliability,
|
|
60
|
+
* no false-positive surface.
|
|
61
|
+
*
|
|
62
|
+
* 2. `process.env._` ends with `/npx` (or `\npx` on Windows) —
|
|
63
|
+
* legacy fallback for shells that set `_` to the launched
|
|
64
|
+
* command. Defensive against shim layouts where argv[1] has
|
|
65
|
+
* been symlinked through a path that doesn't contain `_npx`.
|
|
66
|
+
*
|
|
67
|
+
* Why NOT `process.env.npm_command === "exec"`: this signal was
|
|
68
|
+
* considered but rejected in v3.1.1 review. `npm_command=exec` is
|
|
69
|
+
* also set when a stable-install user runs `skillrepo init` from a
|
|
70
|
+
* `package.json` lifecycle script (e.g. `"postinstall": "skillrepo
|
|
71
|
+
* init --yes"`) or invokes `npm exec skillrepo ...` directly. In
|
|
72
|
+
* those cases the user has a real global install and should NOT
|
|
73
|
+
* have session-sync skipped or see `npx skillrepo` in Next Steps.
|
|
74
|
+
* The argv[1] signal already catches real npx invocations
|
|
75
|
+
* unambiguously; adding npm_command trades a minor coverage gain
|
|
76
|
+
* (shim layouts) for a false-positive surface that affects real
|
|
77
|
+
* users. See v3.1.1 PR review cycle for the full discussion.
|
|
78
|
+
*
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function isNpxInvocation() {
|
|
82
|
+
const execPath = process.argv[1] ?? "";
|
|
83
|
+
if (execPath.includes("/_npx/") || execPath.includes("\\_npx\\")) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const underscore = process.env._ ?? "";
|
|
87
|
+
if (underscore.endsWith("/npx") || underscore.endsWith("\\npx")) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
24
93
|
/**
|
|
25
94
|
* @typedef {Object} ResolvedFlags
|
|
26
95
|
* @property {string} serverUrl
|
|
@@ -85,6 +154,15 @@ export function resolveFlags(argv, opts = {}) {
|
|
|
85
154
|
} else if (arg === "--help" || arg === "-h") {
|
|
86
155
|
// Dispatcher should have intercepted this. Defensive no-op.
|
|
87
156
|
continue;
|
|
157
|
+
} else if (arg === "--verbose") {
|
|
158
|
+
// Global flag set by the dispatcher into SKILLREPO_VERBOSE=1
|
|
159
|
+
// so http.mjs's retry logger can honor it. It's a first-class
|
|
160
|
+
// flag, not an unknown arg — accept it silently in every
|
|
161
|
+
// command that passes through resolveFlags. Before this
|
|
162
|
+
// branch existed, any command that consumed argv via
|
|
163
|
+
// resolveFlags rejected `--verbose` with "Unknown argument",
|
|
164
|
+
// breaking the flag documented in the top-level --help.
|
|
165
|
+
continue;
|
|
88
166
|
} else {
|
|
89
167
|
// Allow the caller to consume a positional arg before we treat
|
|
90
168
|
// it as unknown. This is how `get @owner/name` and
|
package/src/lib/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -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
|
|
566
|
-
|
|
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.
|
package/src/lib/fs-utils.mjs
CHANGED
|
@@ -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 &&
|
|
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);
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
|
|
72
72
|
import { existsSync, readFileSync } from "node:fs";
|
|
73
73
|
import { execFileSync } from "node:child_process";
|
|
74
|
+
import { isAbsolute } from "node:path";
|
|
74
75
|
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
75
76
|
import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
|
|
76
77
|
import {
|
|
@@ -79,21 +80,43 @@ import {
|
|
|
79
80
|
} from "../paths.mjs";
|
|
80
81
|
import { diskError, validationError } from "../errors.mjs";
|
|
81
82
|
import { removeSettingsSessionHook } from "../removers/settings.mjs";
|
|
83
|
+
import { isNpxInvocation } from "../cli-config.mjs";
|
|
84
|
+
import { platformConventions } from "../platform.mjs";
|
|
82
85
|
|
|
83
86
|
/**
|
|
84
87
|
* Build the hook command string for a given absolute path. Exported
|
|
85
88
|
* so tests can assert the exact bytes the installer writes.
|
|
86
89
|
*
|
|
90
|
+
* Shell shape is platform-specific — see `platform.mjs` for the full
|
|
91
|
+
* rationale. Summary:
|
|
92
|
+
*
|
|
93
|
+
* - **POSIX** (macOS, Linux): `<path> update --session-hook 2>&1 || true`.
|
|
94
|
+
* `|| true` catches any non-zero exit at the shell level; primary
|
|
95
|
+
* defense is the `--session-hook` flag contract in the Node process.
|
|
96
|
+
* - **Windows** (cmd.exe / PowerShell): `<path> update --session-hook 2>&1`.
|
|
97
|
+
* `|| true` omitted because cmd.exe doesn't know the `true` builtin.
|
|
98
|
+
* `--session-hook` contract is the only defense; consequences of
|
|
99
|
+
* binary-vanished scenarios are slightly noisier in Claude Code's
|
|
100
|
+
* session log but still non-blocking.
|
|
101
|
+
*
|
|
102
|
+
* The suffix is supplied by `platformConventions().hookShellSuffix` —
|
|
103
|
+
* this function doesn't know which OS it's targeting, it just
|
|
104
|
+
* concatenates the convention's suffix.
|
|
105
|
+
*
|
|
87
106
|
* @param {string} binaryPath - Absolute path to the `skillrepo` binary.
|
|
107
|
+
* @param {object} [options]
|
|
108
|
+
* @param {NodeJS.Platform} [options.platform] - Override for testing.
|
|
109
|
+
* Default: `os.platform()`.
|
|
88
110
|
* @returns {string} The full shell command string.
|
|
89
111
|
*/
|
|
90
|
-
export function buildHookCommand(binaryPath) {
|
|
112
|
+
export function buildHookCommand(binaryPath, { platform: platformOverride } = {}) {
|
|
91
113
|
if (typeof binaryPath !== "string" || binaryPath.length === 0) {
|
|
92
114
|
throw validationError(
|
|
93
115
|
"buildHookCommand: binaryPath must be a non-empty string.",
|
|
94
116
|
);
|
|
95
117
|
}
|
|
96
|
-
|
|
118
|
+
const conv = platformConventions({ platform: platformOverride });
|
|
119
|
+
return `${binaryPath} update --session-hook 2>&1${conv.hookShellSuffix}`;
|
|
97
120
|
}
|
|
98
121
|
|
|
99
122
|
/**
|
|
@@ -104,27 +127,56 @@ export function buildHookCommand(binaryPath) {
|
|
|
104
127
|
*
|
|
105
128
|
* @returns {string | null}
|
|
106
129
|
*/
|
|
107
|
-
export function resolveSkillrepoBinary() {
|
|
130
|
+
export function resolveSkillrepoBinary({ platform: platformOverride } = {}) {
|
|
131
|
+
// npx-invocation guard. Returns null early before any OS-specific
|
|
132
|
+
// logic runs — npx detection is platform-neutral (argv and env
|
|
133
|
+
// checks only) so it doesn't need the conventions object.
|
|
134
|
+
if (isNpxInvocation()) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Platform-specific binary locator name comes from the single
|
|
139
|
+
// source of truth in platform.mjs. Adding a new locator for a
|
|
140
|
+
// new platform is one edit in platform.mjs, not a scattered
|
|
141
|
+
// search for `platform() === "win32"` conditionals. See
|
|
142
|
+
// platform.mjs for the full rationale.
|
|
143
|
+
const conv = platformConventions({ platform: platformOverride });
|
|
144
|
+
|
|
108
145
|
try {
|
|
109
|
-
// 3-second timeout — `which` typically
|
|
110
|
-
// but a PATH that includes a network filesystem
|
|
111
|
-
// alias that does I/O could hang indefinitely.
|
|
112
|
-
// call ensures `skillrepo init` never stalls on
|
|
113
|
-
// resolution.
|
|
114
|
-
const
|
|
146
|
+
// 3-second timeout — `which`/`where` typically return in
|
|
147
|
+
// milliseconds, but a PATH that includes a network filesystem
|
|
148
|
+
// or a shell alias that does I/O could hang indefinitely.
|
|
149
|
+
// Bounding the call ensures `skillrepo init` never stalls on
|
|
150
|
+
// binary resolution.
|
|
151
|
+
const raw = execFileSync(conv.binaryLocator, ["skillrepo"], {
|
|
115
152
|
encoding: "utf-8",
|
|
116
153
|
stdio: ["ignore", "pipe", "ignore"],
|
|
117
154
|
timeout: 3000,
|
|
118
|
-
})
|
|
155
|
+
});
|
|
156
|
+
// Windows `where` can return multiple matching paths (one per
|
|
157
|
+
// PATH entry containing the binary) on separate lines. Take
|
|
158
|
+
// only the first. `which` always returns a single path but the
|
|
159
|
+
// split is harmless there. This is the one line where the
|
|
160
|
+
// platform difference actually leaks through — all platforms
|
|
161
|
+
// receive potentially-multi-line output that we canonicalize
|
|
162
|
+
// the same way.
|
|
163
|
+
const result = raw.split(/\r?\n/)[0].trim();
|
|
119
164
|
if (!result) return null;
|
|
120
|
-
// Sanity: the resolved path must be absolute. A relative
|
|
121
|
-
// would be meaningless at session-start time because
|
|
122
|
-
// Code hook runner's cwd is undefined.
|
|
123
|
-
|
|
165
|
+
// Sanity: the resolved path must be absolute. A relative
|
|
166
|
+
// result would be meaningless at session-start time because
|
|
167
|
+
// the Claude Code hook runner's cwd is undefined. `isAbsolute`
|
|
168
|
+
// handles both POSIX (`/foo/bar`) and Windows (`C:\foo\bar`)
|
|
169
|
+
// path styles — it's Node's built-in cross-platform check,
|
|
170
|
+
// not a platform-conditional we need to own.
|
|
171
|
+
if (!isAbsolute(result)) return null;
|
|
124
172
|
return result;
|
|
125
173
|
} catch {
|
|
126
|
-
//
|
|
127
|
-
//
|
|
174
|
+
// Locator exits non-zero if the binary isn't on PATH, or throws
|
|
175
|
+
// ENOENT if the locator itself isn't available (e.g. a minimal
|
|
176
|
+
// container image without `which`, or a Windows system with
|
|
177
|
+
// `where.exe` missing which is effectively never — but still
|
|
178
|
+
// safe-handled). Either way: null → caller routes to the
|
|
179
|
+
// architect-specified "requires stable install" skip message.
|
|
128
180
|
return null;
|
|
129
181
|
}
|
|
130
182
|
}
|
|
@@ -140,6 +192,16 @@ export function resolveSkillrepoBinary() {
|
|
|
140
192
|
* @param {boolean} [options.global=false] - When true, installs to the
|
|
141
193
|
* user-global `~/.claude/settings.local.json` instead of the
|
|
142
194
|
* project-local file.
|
|
195
|
+
* @param {NodeJS.Platform} [options.platform] - Override for testing.
|
|
196
|
+
* Propagated to both `resolveSkillrepoBinary` and
|
|
197
|
+
* `buildHookCommand` so a test can exercise the full
|
|
198
|
+
* installer path under simulated Windows semantics on a
|
|
199
|
+
* non-Windows host. Production callers leave this unset so
|
|
200
|
+
* both helpers see the real `os.platform()`. This option is
|
|
201
|
+
* the mechanism that closes the architect's round-3 HIGH
|
|
202
|
+
* finding — without it, a Windows-shaped `binaryPath` passed
|
|
203
|
+
* in the test still got a POSIX-shaped command back because
|
|
204
|
+
* `buildHookCommand` read `os.platform()` directly.
|
|
143
205
|
* @returns {{
|
|
144
206
|
* path: string;
|
|
145
207
|
* action: "installed" | "updated" | "unchanged" | "skipped";
|
|
@@ -158,23 +220,41 @@ export function resolveSkillrepoBinary() {
|
|
|
158
220
|
export function mergeSessionHook({
|
|
159
221
|
binaryPath: binaryPathOpt,
|
|
160
222
|
global = false,
|
|
223
|
+
platform: platformOverride,
|
|
161
224
|
} = {}) {
|
|
162
225
|
const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
|
|
163
226
|
const displayPath = global
|
|
164
227
|
? "~/.claude/settings.local.json"
|
|
165
228
|
: ".claude/settings.local.json";
|
|
166
229
|
|
|
167
|
-
const binaryPath =
|
|
230
|
+
const binaryPath =
|
|
231
|
+
binaryPathOpt ?? resolveSkillrepoBinary({ platform: platformOverride });
|
|
168
232
|
if (!binaryPath) {
|
|
233
|
+
// Two reasons binaryPath can be null:
|
|
234
|
+
// 1. `isNpxInvocation()` returned true — the user ran
|
|
235
|
+
// `npx skillrepo ...`. The npx cache path is transient and
|
|
236
|
+
// unsuitable for baking into a long-lived hook command.
|
|
237
|
+
// 2. `which skillrepo` returned nothing — no global install
|
|
238
|
+
// exists at all.
|
|
239
|
+
// Both are the same problem from the hook's perspective: we
|
|
240
|
+
// can't produce a command that will still work later. The
|
|
241
|
+
// architect's #884 design specified the same warning text for
|
|
242
|
+
// both cases.
|
|
169
243
|
return {
|
|
170
244
|
path: displayPath,
|
|
171
245
|
action: "skipped",
|
|
172
246
|
reason:
|
|
173
|
-
"
|
|
247
|
+
"Session sync requires a stable `skillrepo` binary on PATH. " +
|
|
248
|
+
"Under `npx skillrepo ...` or without a global install, the " +
|
|
249
|
+
"hook would bind to a transient path that eventually breaks. " +
|
|
250
|
+
"Install globally with `npm install -g skillrepo` and re-run " +
|
|
251
|
+
"`skillrepo session-sync enable`.",
|
|
174
252
|
};
|
|
175
253
|
}
|
|
176
254
|
|
|
177
|
-
const desiredCommand = buildHookCommand(binaryPath
|
|
255
|
+
const desiredCommand = buildHookCommand(binaryPath, {
|
|
256
|
+
platform: platformOverride,
|
|
257
|
+
});
|
|
178
258
|
|
|
179
259
|
// Parse existing file (or start fresh). A corrupt-but-present file
|
|
180
260
|
// is a hard error: silently overwriting it would destroy any user-
|