@ericrisco/rsc 1.0.30 → 1.0.31

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.30",
2
+ "version": "1.0.31",
3
3
  "counts": {
4
4
  "skills": 276
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -146,9 +146,20 @@ function safeRelative(cwd, absPath) {
146
146
  return rel.split(sep).join('/');
147
147
  }
148
148
 
149
+ // A manifest path is split on BOTH separators, because the input is file content — readManifest()
150
+ // reads it off disk — and not every manifest was written by safeRelative() on this platform. A path
151
+ // stored with Windows separators (`..\..\somewhere`) has no '/' to split on, so the old check saw a
152
+ // single segment, found no '..', and handed the raw string to join() — which honours backslashes on
153
+ // Windows. The guard only guarded on POSIX.
154
+ //
155
+ // Splitting into real segments does both jobs at once: `..` becomes visible to the check, and the
156
+ // path is rebuilt by join() rather than passed through, so the separator the manifest happened to
157
+ // use stops mattering. Found while fixing the Windows hook-duplication bug: same family.
158
+ const pathSegments = (value) => value.split(/[/\\]+/).filter(Boolean);
159
+
149
160
  function safeJoin(cwd, relPath) {
150
- if (!relPath || isAbsolute(relPath) || relPath.split('/').includes('..')) {
161
+ if (!relPath || isAbsolute(relPath) || pathSegments(relPath).includes('..')) {
151
162
  throw new Error(`path is outside project root: ${relPath}`);
152
163
  }
153
- return join(cwd, ...relPath.split('/'));
164
+ return join(cwd, ...pathSegments(relPath));
154
165
  }
package/targets/claude.js CHANGED
@@ -14,6 +14,31 @@ export function writeSkill(id, fromDir, toPath) {
14
14
  return linkOrCopy(fromDir, toPath);
15
15
  }
16
16
 
17
+ // A hook entry, stringified with its path separators normalized to `/`.
18
+ //
19
+ // This exists because of a Windows bug that cost real users 4× the context budget. `join()` yields
20
+ // `.rsc\session-start.mjs` on Windows, `JSON.stringify` escapes that to `.rsc\\session-start.mjs`,
21
+ // and all seven dedup filters below searched for a forward-slash needle (`.rsc/session-start.`).
22
+ // The needle never matched, so every `install` / `add` / `sync` APPENDED a hook instead of replacing
23
+ // it: a real Windows box reported every hook present four times, which means the `suggest` always-on
24
+ // body was injected 4× per session start and all six guards ran 4× per command.
25
+ //
26
+ // Worse, and unreported: `unwireHook` uses the same needles, so on Windows `uninstall` and `purge`
27
+ // removed NONE of the hooks they claim to remove — a cleanup that silently cleans nothing.
28
+ //
29
+ // ONE replacement, and only the JSON-escaped form. `JSON.stringify` escapes every backslash as
30
+ // `\\`, so a separator always arrives doubled and a LONE backslash in the JSON text is never a
31
+ // separator — it is some other escape (`\"`, `\n`, `\uXXXX`). Collapsing those too would mangle
32
+ // them for no gain, which is why the second `.replace(/\\/g, '/')` this started with is gone: a
33
+ // mutation pass showed nothing depended on it.
34
+ //
35
+ // Normalizing the HAYSTACK (never the needle) keeps every call site readable as a plain POSIX path,
36
+ // and keeps a user's own `C:\...\my-own-hook.mjs` from ever looking like an rsc hook.
37
+ // `scripts/doctor.js` already handled both forms; this is that knowledge, shared.
38
+ export function hookWiringOf(entry) {
39
+ return JSON.stringify(entry).replace(/\\\\/g, '/');
40
+ }
41
+
17
42
  // Inverse of wireHook: drop every rsc-wired hook entry (any command pointing at a
18
43
  // .rsc/ script — session-start, worklog-checkpoint, ship-guard, danger-guard, … —
19
44
  // plus the legacy cat-form) from settings.json, across all events. User hooks and
@@ -27,7 +52,7 @@ export function unwireHook(paths) {
27
52
  if (!settings.hooks) return [];
28
53
  for (const event of Object.keys(settings.hooks)) {
29
54
  settings.hooks[event] = (settings.hooks[event] || []).filter((e) => {
30
- const s = JSON.stringify(e);
55
+ const s = hookWiringOf(e);
31
56
  return !s.includes('.rsc/') && !s.includes('skills/rsc/suggest');
32
57
  });
33
58
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
@@ -61,7 +86,7 @@ export function wireHook(paths) {
61
86
  settings.hooks ||= {};
62
87
  settings.hooks.SessionStart ||= [];
63
88
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => {
64
- const s = JSON.stringify(e);
89
+ const s = hookWiringOf(e);
65
90
  // drop legacy cat-form and any prior session-start script (.sh from the bash era, or .mjs)
66
91
  return !s.includes('skills/rsc/suggest') && !s.includes('.rsc/session-start.');
67
92
  });
@@ -78,7 +103,7 @@ export function wireHook(paths) {
78
103
  for (const event of ['PreCompact', 'SessionEnd']) {
79
104
  settings.hooks[event] ||= [];
80
105
  settings.hooks[event] = settings.hooks[event].filter(
81
- (e) => !JSON.stringify(e).includes('.rsc/worklog-checkpoint.'),
106
+ (e) => !hookWiringOf(e).includes('.rsc/worklog-checkpoint.'),
82
107
  );
83
108
  settings.hooks[event].push({ hooks: [{ type: 'command', command: wlCmd }] });
84
109
  }
@@ -96,7 +121,7 @@ export function wireHook(paths) {
96
121
  const sgCmd = `node "${sgDest}" "${paths.projectRoot}"`;
97
122
  settings.hooks.PreToolUse ||= [];
98
123
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
99
- (e) => !JSON.stringify(e).includes('.rsc/ship-guard.'),
124
+ (e) => !hookWiringOf(e).includes('.rsc/ship-guard.'),
100
125
  );
101
126
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
102
127
 
@@ -109,7 +134,7 @@ export function wireHook(paths) {
109
134
  copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
110
135
  const dgCmd = `node "${dgDest}" "${paths.projectRoot}"`;
111
136
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
112
- (e) => !JSON.stringify(e).includes('.rsc/danger-guard.'),
137
+ (e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
113
138
  );
114
139
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
115
140
 
@@ -124,7 +149,7 @@ export function wireHook(paths) {
124
149
  copyFileSync(join(HERE, 'gitmoji-guard.mjs'), gmDest);
125
150
  const gmCmd = `node "${gmDest}" "${paths.projectRoot}"`;
126
151
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
127
- (e) => !JSON.stringify(e).includes('.rsc/gitmoji-guard.'),
152
+ (e) => !hookWiringOf(e).includes('.rsc/gitmoji-guard.'),
128
153
  );
129
154
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: gmCmd }] });
130
155
 
@@ -141,7 +166,7 @@ export function wireHook(paths) {
141
166
  const fgCmd = `node "${fgDest}" "${paths.projectRoot}"`;
142
167
  settings.hooks.UserPromptSubmit ||= [];
143
168
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
144
- (e) => !JSON.stringify(e).includes('.rsc/userprompt-gate.'),
169
+ (e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
145
170
  );
146
171
  settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
147
172