@ericrisco/rsc 2.0.6 → 2.0.8

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": "2.0.6",
2
+ "version": "2.0.8",
3
3
  "counts": {
4
4
  "skills": 273,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,12 @@ function agentSpecFor(target, root) {
44
44
  return { dir: dirname(p), ext };
45
45
  }
46
46
 
47
+ // A real file, or a link that leads to one. `statSync` follows; a dangling link throws and is
48
+ // therefore not a file, which is the answer we want.
49
+ function isFile(path) {
50
+ try { return statSync(path).isFile(); } catch { return false; }
51
+ }
52
+
47
53
  // Real files only. Directories (a dir named `foo.md` is not an agent), dangling
48
54
  // symlinks and dotfiles are not capabilities.
49
55
  function entriesWithExt(dir, ext) {
@@ -96,8 +102,19 @@ export function listSkills({ target, home, cwd = process.cwd() } = {}) {
96
102
  try {
97
103
  for (const e of readdirSync(paths.dir, { withFileTypes: true })) {
98
104
  if (e.name.startsWith('.')) continue;
99
- if (ext) { if (e.isFile() && e.name.endsWith(ext)) out.push({ id: e.name.slice(0, -ext.length), scope }); }
100
- else if (e.isDirectory() && existsSync(join(paths.dir, e.name, 'SKILL.md'))) out.push({ id: e.name, scope });
105
+ // Ask what a skill IS, never how it was put on disk. A `Dirent` from `withFileTypes`
106
+ // describes the LINK and not its target, so `isDirectory()`/`isFile()` are both false for
107
+ // every skill on macOS and Linux — where a symlink per assistant, pointing at one shared
108
+ // copy, is the layout the installer deliberately writes. Windows gets real files (relative
109
+ // dir symlinks need Developer Mode there), which is why this read as correct for so long
110
+ // and why CI never caught it.
111
+ //
112
+ // `existsSync` and `statSync` follow links on their own, so one question answers all three
113
+ // platforms with no branch: is there a SKILL.md behind this name? Following a link is not
114
+ // the same as believing anything — a dangling link, a directory with no SKILL.md and a
115
+ // plain file all still fail it.
116
+ if (ext) { if (e.name.endsWith(ext) && isFile(join(paths.dir, e.name))) out.push({ id: e.name.slice(0, -ext.length), scope }); }
117
+ else if (existsSync(join(paths.dir, e.name, 'SKILL.md'))) out.push({ id: e.name, scope });
101
118
  }
102
119
  } catch { /* unreadable scope → contributes nothing */ }
103
120
  }
@@ -18,8 +18,8 @@
18
18
  // read. Anything it cannot read with certainty (editor commit, -F file, --amend
19
19
  // --no-edit, an unparseable command) is ALLOWED: a guard that guesses is a guard that
20
20
  // gets turned off. Every deny names its recovery. Opt out with .rsc/.no-gitmoji.
21
- import { pathToFileURL } from 'node:url';
22
- import { existsSync, readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
 
25
25
  // The official gitmoji set (gitmoji.dev/api/gitmojis), as data: [emoji, code, semver, meaning].
@@ -222,7 +222,34 @@ export function denyMessage(message) {
222
222
 
223
223
  // ---- hook entrypoint -----------------------------------------------------------
224
224
  // Skipped when imported by a test (P2: the mechanism is testable without a subprocess).
225
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
225
+ /**
226
+ * "Was this file run directly?" — the same question `clone-bootstrap.mjs` answers, and the same two
227
+ * traps, so the same answer. Written out here rather than imported because hooks are materialized
228
+ * file by file under `.rsc/`: an import would be a second file to copy for six lines.
229
+ *
230
+ * `process.argv[1]` MAY NOT EXIST. Under `node -e`, `--input-type=module` or the REPL there is no
231
+ * script path, and `pathToFileURL(undefined)` throws — at module load, so merely IMPORTING this
232
+ * file takes the importer down with it. A module that cannot be imported is a module nobody can
233
+ * build a tool on top of, and people do exactly that with this package.
234
+ *
235
+ * SYMLINKS MAKE THE TWO SIDES DISAGREE. `import.meta.url` is what the loader resolved (symlinks
236
+ * RESOLVED); `process.argv[1]` is the raw string the client passed (symlinks INTACT). One
237
+ * symlinked component — `/tmp` and `/var` on macOS, or an ordinary `~/code -> /Volumes/…` — and a
238
+ * string compare says "no", the main block never runs, node exits 0 having printed nothing, and
239
+ * the hook becomes a silent no-op that `doctor` still reports as wired.
240
+ */
241
+ function isMainModule(metaUrl) {
242
+ const invoked = process.argv[1];
243
+ if (!invoked) return false;
244
+ const self = fileURLToPath(metaUrl);
245
+ try {
246
+ return realpathSync(self) === realpathSync(invoked);
247
+ } catch {
248
+ return self === invoked;
249
+ }
250
+ }
251
+
252
+ if (isMainModule(import.meta.url)) {
226
253
  const root = process.argv[2] || process.cwd();
227
254
  const allow = () => process.exit(0);
228
255
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { pathToFileURL } from 'node:url';
3
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
4
4
  import { resolve, join, dirname } from 'node:path';
5
5
  import { capture, resume } from './session-memory-core.mjs';
6
6
 
@@ -126,7 +126,34 @@ function stdinJson() {
126
126
  }
127
127
  }
128
128
 
129
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
129
+ /**
130
+ * "Was this file run directly?" — the same question `clone-bootstrap.mjs` answers, and the same two
131
+ * traps, so the same answer. Written out here rather than imported because hooks are materialized
132
+ * file by file under `.rsc/`: an import would be a second file to copy for six lines.
133
+ *
134
+ * `process.argv[1]` MAY NOT EXIST. Under `node -e`, `--input-type=module` or the REPL there is no
135
+ * script path, and `pathToFileURL(undefined)` throws — at module load, so merely IMPORTING this
136
+ * file takes the importer down with it. A module that cannot be imported is a module nobody can
137
+ * build a tool on top of, and people do exactly that with this package.
138
+ *
139
+ * SYMLINKS MAKE THE TWO SIDES DISAGREE. `import.meta.url` is what the loader resolved (symlinks
140
+ * RESOLVED); `process.argv[1]` is the raw string the client passed (symlinks INTACT). One
141
+ * symlinked component — `/tmp` and `/var` on macOS, or an ordinary `~/code -> /Volumes/…` — and a
142
+ * string compare says "no", the main block never runs, node exits 0 having printed nothing, and
143
+ * the hook becomes a silent no-op that `doctor` still reports as wired.
144
+ */
145
+ function isMainModule(metaUrl) {
146
+ const invoked = process.argv[1];
147
+ if (!invoked) return false;
148
+ const self = fileURLToPath(metaUrl);
149
+ try {
150
+ return realpathSync(self) === realpathSync(invoked);
151
+ } catch {
152
+ return self === invoked;
153
+ }
154
+ }
155
+
156
+ if (isMainModule(import.meta.url)) {
130
157
  const result = handleLifecycle({ target: process.argv[2], event: process.argv[3], native: stdinJson() });
131
158
  process.stdout.write(`${JSON.stringify(result.output)}\n`);
132
159
  }