@ericrisco/rsc 1.1.4 → 1.1.6

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 (66) hide show
  1. package/README.md +81 -11
  2. package/manifest.json +56 -231
  3. package/package.json +1 -1
  4. package/scripts/doctor.js +68 -1
  5. package/scripts/install-apply.js +91 -16
  6. package/scripts/lib/divergence.js +33 -0
  7. package/scripts/lib/domains.js +3 -3
  8. package/scripts/lib/manifest-file.js +53 -0
  9. package/scripts/lib/repair.js +156 -0
  10. package/scripts/lib/ui.js +5 -0
  11. package/scripts/rsc.js +84 -11
  12. package/skills/design/SKILL.md +3 -3
  13. package/skills/design/references/design-systems.md +1 -1
  14. package/skills/design/references/inspiration-sources.md +2 -2
  15. package/skills/design/references/motion-and-interaction.md +2 -2
  16. package/skills/design-dna/evals/cases.yaml +1 -1
  17. package/skills/design-loop/SKILL.md +3 -3
  18. package/skills/design-loop/evals/cases.yaml +3 -3
  19. package/skills/expo/SKILL.md +1 -1
  20. package/skills/motion-craft/SKILL.md +125 -0
  21. package/skills/motion-craft/evals/cases.yaml +38 -0
  22. package/skills/motion-craft/scripts/verify.sh +75 -0
  23. package/skills/react/SKILL.md +2 -2
  24. package/skills/react-native/SKILL.md +1 -1
  25. package/skills/suggest/SKILL.md +25 -1
  26. package/skills/swift-ios/SKILL.md +2 -2
  27. package/skills/ui-engineering/SKILL.md +114 -0
  28. package/skills/ui-engineering/evals/cases.yaml +38 -0
  29. package/skills/variant-explorer/SKILL.md +93 -0
  30. package/skills/variant-explorer/evals/cases.yaml +38 -0
  31. package/targets/_md-block.js +17 -4
  32. package/targets/claude.js +17 -7
  33. package/targets/index.js +66 -19
  34. package/skills/animate/SKILL.md +0 -851
  35. package/skills/animate/evals/cases.yaml +0 -36
  36. package/skills/animate/references/recipes.md +0 -324
  37. package/skills/animate-expo/SKILL.md +0 -281
  38. package/skills/animate-expo/evals/cases.yaml +0 -36
  39. package/skills/animate-expo/references/recipes.md +0 -385
  40. package/skills/animation-vocabulary/SKILL.md +0 -189
  41. package/skills/animation-vocabulary/evals/cases.yaml +0 -31
  42. package/skills/apple-design/SKILL.md +0 -301
  43. package/skills/apple-design/evals/cases.yaml +0 -33
  44. package/skills/ask-sonner/SKILL.md +0 -104
  45. package/skills/ask-sonner/evals/cases.yaml +0 -33
  46. package/skills/ask-sonner/references/api.md +0 -64
  47. package/skills/design-eng/SKILL.md +0 -88
  48. package/skills/design-eng/evals/cases.yaml +0 -36
  49. package/skills/find-animation-opportunities/SKILL.md +0 -150
  50. package/skills/find-animation-opportunities/evals/cases.yaml +0 -31
  51. package/skills/improve-animations/SKILL.md +0 -126
  52. package/skills/improve-animations/evals/cases.yaml +0 -31
  53. package/skills/improve-animations/references/audit.md +0 -115
  54. package/skills/improve-animations/references/plan-template.md +0 -73
  55. package/skills/pick-ui-library/SKILL.md +0 -101
  56. package/skills/pick-ui-library/evals/cases.yaml +0 -33
  57. package/skills/pick-ui-library/references/component-galleries.md +0 -59
  58. package/skills/prototype/SKILL.md +0 -113
  59. package/skills/prototype/evals/cases.yaml +0 -31
  60. package/skills/prototype/references/picker.md +0 -197
  61. package/skills/review-animations/SKILL.md +0 -188
  62. package/skills/review-animations/evals/cases.yaml +0 -34
  63. package/skills/review-animations/references/standards.md +0 -187
  64. package/skills/review-animations/scripts/verify.sh +0 -164
  65. package/skills/write-swift/SKILL.md +0 -413
  66. package/skills/write-swift/evals/cases.yaml +0 -36
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
3
- import { join, dirname, basename } from 'node:path';
2
+ import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
3
+ import { join, dirname, basename, relative, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { planInstall } from './install-plan.js';
6
6
  import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
7
7
  import { targetHasAgents, writeAgents, removeAgents, agentPath, agentNames } from '../targets/agents.js';
8
8
  import { readState, writeState } from './lib/state.js';
9
+ import { readManifest, writeManifest } from './lib/manifest-file.js';
9
10
  import { createBackup } from './lib/backups.js';
10
11
 
11
12
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -67,7 +68,7 @@ function generatedHookFiles({ target, cwd }) {
67
68
  ];
68
69
  }
69
70
 
70
- function managedPathsForInstall({ skillIds, target, home, cwd }) {
71
+ export function managedPathsForInstall({ skillIds, target, home, cwd }) {
71
72
  const paths = targetPaths(target, home, cwd);
72
73
  const plan = planInstall({ skillIds, target, home, cwd });
73
74
  const out = [paths.stateFile, versionFile(cwd), baseVersionsFile(cwd)];
@@ -82,6 +83,43 @@ function managedPathsForInstall({ skillIds, target, home, cwd }) {
82
83
  return [...new Set(out)];
83
84
  }
84
85
 
86
+ // The opt-outs a team disarmed and the developer tier live as marker files under .rsc/,
87
+ // which is machine-local and therefore lost on every clone: a team that disarmed the
88
+ // gitmoji guard found it armed again on the next checkout, with nobody having decided
89
+ // that. They are decisions, so they belong in the committed declaration.
90
+ function localDecisions(cwd) {
91
+ const dir = join(cwd, '.rsc');
92
+ let optOuts = [];
93
+ try {
94
+ optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)).sort();
95
+ } catch { /* no .rsc yet */ }
96
+ let tier = null;
97
+ try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
98
+ return { optOuts, tier };
99
+ }
100
+
101
+ // Record the decision, merging so a second assistant never erases the first: installing
102
+ // into codex on a machine that already had claude is adding, not replacing. Union, sorted
103
+ // where order carries no meaning, so the file stays diffable and a merge conflict stays
104
+ // readable.
105
+ export function recordInManifest({ cwd, target, skillIds, catalogVersion = CLI_VERSION, dropTarget }) {
106
+ const prev = readManifest(cwd) || { targets: [], skills: [], ownSkills: [], optOuts: [] };
107
+ const { optOuts, tier } = localDecisions(cwd);
108
+ const union = (a, b) => [...new Set([...(a || []), ...(b || [])])].sort();
109
+ return writeManifest(cwd, {
110
+ version: 1,
111
+ // Union by default: installing into a second assistant is adding, not replacing.
112
+ // `dropTarget` is the one exception — a MOVE, where leaving the old one declared would
113
+ // have every clone rebuild a harness that was deliberately abandoned.
114
+ targets: union(prev.targets, [target]).filter((t) => t !== dropTarget),
115
+ skills: union(prev.skills, skillIds),
116
+ ownSkills: prev.ownSkills || [],
117
+ catalogVersion,
118
+ tier: tier ?? prev.tier ?? null,
119
+ optOuts: optOuts.length ? optOuts : (prev.optOuts || []),
120
+ });
121
+ }
122
+
85
123
  export async function applyInstall({ skillIds, target, home, cwd = process.cwd(), operation = 'install', dryRun = false }) {
86
124
  const paths = targetPaths(target, home, cwd);
87
125
  const plan = planInstall({ skillIds, target, home, cwd });
@@ -120,7 +158,8 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
120
158
  writeState(paths.stateFile, state);
121
159
  mkdirSync(dirname(versionFile(cwd)), { recursive: true });
122
160
  writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
123
- ignoreLocalState(cwd);
161
+ recordInManifest({ cwd, target, skillIds });
162
+ ignoreLocalState(cwd, target);
124
163
  return { ...state, backup };
125
164
  }
126
165
 
@@ -132,23 +171,54 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
132
171
  // Additive and idempotent by construction: append one line to an existing
133
172
  // .gitignore, never rewrite or reorder it, and never create the file in a directory
134
173
  // that is not a git repository.
135
- export function ignoreLocalState(cwd = process.cwd()) {
174
+ export function ignoreLocalState(cwd = process.cwd(), target) {
136
175
  if (!existsSync(join(cwd, '.git'))) return null;
137
176
  const gi = join(cwd, '.gitignore');
138
177
  let text = '';
139
178
  try { text = existsSync(gi) ? readFileSync(gi, 'utf8') : ''; } catch { return null; }
140
- const already = text.split('\n').some((l) => {
141
- const s = l.trim().replace(/\/$/, '');
142
- return s === '.rsc' || s === '/.rsc';
143
- });
144
- if (already) return null;
179
+
180
+ // `.rsc/` is machine state. The per-target skill entries are derived: symlinks here,
181
+ // real copies on Windows, so committing either shape puts two incompatible forms of the
182
+ // same thing into one repo.
183
+ //
184
+ // But `claude` writes into `.claude/skills/` and `cursor` into `.cursor/rules/`, which
185
+ // are SHARED with the user — their own skills and rules live there too. Excluding the
186
+ // whole directory would quietly stop versioning their work, which is the same sin this
187
+ // release exists to fix: treating what is theirs as if it were ours. So shared
188
+ // directories are excluded entry by entry, and only the entries rsc manages.
189
+ const wanted = ['.rsc/'];
190
+ if (target) {
191
+ const paths = targetPaths(target, undefined, cwd);
192
+ const rel = (abs) => relative(cwd, abs).split(sep).join('/');
193
+ for (const id of Object.keys(readState(paths.stateFile).skills || {})) wanted.push(rel(paths.skillDir(id)));
194
+ // The per-file ledger is derived AND platform-shaped (symlink here, real copy on
195
+ // Windows), so committing it puts two incompatible inventories of one thing in one
196
+ // repo. The declaration that does travel is .rsc.json, in the root.
197
+ wanted.push(rel(paths.stateFile));
198
+ // Subagents are catalog content, re-materialised by every install and sync.
199
+ for (const f of agentNames()) wanted.push(rel(agentPath(target, cwd, f)));
200
+ }
201
+ // `.rsc`, `/.rsc` and `.rsc/` are the same rule to git. Normalising both ends is what
202
+ // keeps this idempotent against a .gitignore a human wrote in their own spelling.
203
+ const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
204
+ const present = new Set(text.split('\n').map(norm));
205
+ const add = wanted.filter((w) => !present.has(norm(w)));
206
+ if (!add.length) return null;
207
+
208
+ // Append only. Never rewrite, never reorder: the rest of that file is theirs.
145
209
  const prefix = text === '' ? '' : (text.endsWith('\n') ? '' : '\n');
146
210
  try {
147
- appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) — machine-local, never committed\n.rsc/\n`);
211
+ appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) and managed skill links — machine-local\n${add.join('\n')}\n`);
148
212
  return gi;
149
213
  } catch { return null; }
150
214
  }
151
215
 
216
+ export function collisions({ cwd = process.cwd(), target, home, skillIds = [] }) {
217
+ const paths = targetPaths(target, home, cwd);
218
+ const managed = new Set(Object.keys(readState(paths.stateFile).skills || {}));
219
+ return skillIds.filter((id) => !managed.has(id) && existsSync(paths.skillDir(id)));
220
+ }
221
+
152
222
  export function listInstalled({ target, home, cwd = process.cwd() }) {
153
223
  const paths = targetPaths(target, home, cwd);
154
224
  return Object.keys(readState(paths.stateFile).skills);
@@ -185,17 +255,22 @@ export async function uninstall({ skillIds, target, home, cwd = process.cwd(), d
185
255
  export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun = false }) {
186
256
  const paths = targetPaths(target, home, cwd);
187
257
  const state = readState(paths.stateFile);
258
+ // The per-target state is machine wiring and does not travel, so a fresh clone has
259
+ // none of it: sync found zero skills and did nothing, leaving someone with a repo that
260
+ // declares a harness and has none. The committed manifest is exactly the declaration
261
+ // to rebuild from, and it is sitting right there.
188
262
  const ids = Object.keys(state.skills || {});
189
- if (!ids.length) return dryRun ? { dryRun: true, synced: [], paths: [] } : { synced: [], backup: null };
263
+ const declared = ids.length ? ids : (readManifest(cwd)?.skills || []);
264
+ if (!declared.length) return dryRun ? { dryRun: true, synced: [], paths: [] } : { synced: [], backup: null };
190
265
  if (dryRun) {
191
266
  return {
192
267
  dryRun: true,
193
- synced: ids,
194
- paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
268
+ synced: declared,
269
+ paths: managedPathsForInstall({ skillIds: declared, target, home, cwd }),
195
270
  };
196
271
  }
197
- const nextState = await applyInstall({ skillIds: ids, target, home, cwd, operation: 'sync' });
198
- return { synced: ids, backup: nextState.backup };
272
+ const nextState = await applyInstall({ skillIds: declared, target, home, cwd, operation: 'sync' });
273
+ return { synced: declared, backup: nextState.backup };
199
274
  }
200
275
 
201
276
  // Remove EVERYTHING rsc put in this project: installed skills across all targets,
@@ -0,0 +1,33 @@
1
+ import { readManifest } from './manifest-file.js';
2
+ import { readState } from './state.js';
3
+ import { targetPaths } from '../../targets/index.js';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ // What the team declared versus what is on this machine.
7
+ //
8
+ // The everyday case is not cloning — it is a teammate changing the harness and everyone
9
+ // else running `git pull`. The clone is day one; divergence is every other day. Nothing
10
+ // used to notice, so two people drifted apart in silence.
11
+ //
12
+ // Three buckets, because the three need different answers, and collapsing them is how a
13
+ // report starts nagging someone about their own work:
14
+ // missing — declared catalog skill, not installed here. Aligning installs it.
15
+ // ownMissing — declared team skill, absent from the repo. Aligning says so and writes
16
+ // nothing: it comes from the repo, not from us.
17
+ // extra — installed BY RSC, no longer declared.
18
+ // A hand-written skill that nobody declared is in none of them. Not declaring is a valid
19
+ // way to keep things of your own, not an oversight to correct.
20
+ export function divergence({ cwd = process.cwd(), target, home } = {}) {
21
+ const empty = { missing: [], extra: [], ownMissing: [] };
22
+ const manifest = readManifest(cwd);
23
+ if (!manifest) return empty;
24
+
25
+ const paths = targetPaths(target, home, cwd);
26
+ const installed = new Set(Object.keys(readState(paths.stateFile).skills || {}));
27
+
28
+ return {
29
+ missing: (manifest.skills || []).filter((id) => !installed.has(id)),
30
+ extra: [...installed].filter((id) => !(manifest.skills || []).includes(id)),
31
+ ownMissing: (manifest.ownSkills || []).filter((name) => !existsSync(paths.skillDir(name))),
32
+ };
33
+ }
@@ -17,14 +17,14 @@ export const DOMAINS = [
17
17
  { title: 'AI — build it in', ids: ['building-agents', 'rag', 'embeddings-search', 'prompt-engineering', 'llm-pipeline', 'agent-eval', 'chatbot', 'ai-media', 'replicate-images', 'structured-extraction', 'agent-safety', 'cost-tracking'] },
18
18
  { title: 'AI — run it on', ids: ['replicate', 'runpod', 'modal', 'huggingface', 'ollama', 'together-fireworks', 'fal'] },
19
19
  { title: 'AI — train it', ids: ['machine-learning', 'deep-learning', 'nlp', 'finetuning', 'training-data', 'unsloth', 'open-weights', 'vllm'] },
20
- { title: 'Languages', ids: ['typescript', 'python', 'java', 'csharp-dotnet', 'php', 'ruby', 'cpp', 'elixir', 'bash-scripting', 'sql', 'go', 'write-swift'] },
21
- { title: 'Frameworks & app stacks', ids: ['fastapi', 'nextjs', 'react', 'react-native', 'vue-nuxt', 'angular', 'svelte', 'astro', 'solid-js', 'htmx', 'nodejs', 'nestjs', 'django', 'laravel', 'rails', 'spring-boot', 'phoenix', 'flutter', 'swift-ios', 'kotlin-android', 'compose-multiplatform', 'expo', 'tauri', 'electron', 'rust', 'wordpress', 'shopify', 'no-code-app', 'chrome-extension', 'api-design', 'pick-ui-library', 'ask-sonner'] },
20
+ { title: 'Languages', ids: ['typescript', 'python', 'java', 'csharp-dotnet', 'php', 'ruby', 'cpp', 'elixir', 'bash-scripting', 'sql', 'go'] },
21
+ { title: 'Frameworks & app stacks', ids: ['fastapi', 'nextjs', 'react', 'react-native', 'vue-nuxt', 'angular', 'svelte', 'astro', 'solid-js', 'htmx', 'nodejs', 'nestjs', 'django', 'laravel', 'rails', 'spring-boot', 'phoenix', 'flutter', 'swift-ios', 'kotlin-android', 'compose-multiplatform', 'expo', 'tauri', 'electron', 'rust', 'wordpress', 'shopify', 'no-code-app', 'chrome-extension', 'api-design'] },
22
22
  { title: 'Game development', ids: ['godot', 'unity', 'unreal', 'game-design', 'game-storytelling', 'level-design', 'gamedev-shaders', 'gamedev-multiplayer', 'gamedev-physics', 'gamedev-pathing', 'gamedev-shipping'] },
23
23
  { title: 'Databases & data layer', ids: ['postgresdb', 'mysql', 'mongodb', 'redis', 'supabase', 'neon', 'planetscale', 'sqlite-turso', 'prisma-orm', 'drizzle-orm', 'firebase', 'dynamodb', 'vector-db', 'clickhouse-analytics', 'duckdb', 'db-migrations', 'backups'] },
24
24
  { title: 'Ship & operate — platforms', ids: ['vercel', 'netlify', 'cloudflare', 'railway', 'render', 'fly-io', 'coolify', 'hetzner', 'digitalocean', 'aws-essentials', 'gcp-essentials'] },
25
25
  { title: 'Ship & operate — devops', ids: ['docker', 'github-actions', 'git-workflow', 'domains-dns', 'monitoring', 'email-deliverability', 'scaling', 'deployment', 'deprecation'] },
26
26
  { title: 'Ship & operate — quality & security', ids: ['code-review', 'security-scan', 'secure-coding', 'testing-py', 'testing-web', 'testing-go', 'e2e-testing', 'accessibility', 'performance', 'error-handling', 'observability'] },
27
- { title: 'Motion & interface craft', ids: ['design-eng', 'animate', 'animate-expo', 'review-animations', 'improve-animations', 'find-animation-opportunities', 'animation-vocabulary', 'apple-design', 'prototype'] },
27
+ { title: 'Motion & interface craft', ids: ['motion-craft', 'ui-engineering', 'variant-explorer'] },
28
28
  { title: 'Design & content craft', ids: ['design-loop', 'design', 'design-dna', 'presentations', 'course-storytelling', 'course-builder', 'technical-writing', 'translation-l10n'] },
29
29
  { title: 'Knowledge & meta', ids: ['knowledge-ops', 'codebase-onboarding', 'research-ops', 'decision-records', 'continuous-learning', 'skill-scout', 'context-budget', 'roast-me', 'show-me', 'eli5', 'fable-operator'] },
30
30
  ];
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // `.rsc.json` — the team's decision about this project's harness, committed.
5
+ //
6
+ // It exists because nothing else could travel. The per-target state lives inside the
7
+ // assistant's own directory, which is machine wiring; `syncInstalled()` reads it, so a
8
+ // clone finds zero skills and does nothing. The declaration had to move to the root.
9
+ //
10
+ // It is a DECLARATION, never an inventory: ids and names only, no paths and no file
11
+ // lists. Two reasons. Paths differ by platform (some machines symlink, others copy), so
12
+ // committing them reproduces in the manifest the very conflict this removes from the
13
+ // skill directories. And a committed file gets merge conflicts by nature — one entry per
14
+ // line, stable key order, so resolving one means reading it, not understanding a format.
15
+ export const MANIFEST = '.rsc.json';
16
+ export const manifestPath = (cwd = process.cwd()) => join(cwd, MANIFEST);
17
+
18
+ const KEYS = ['version', 'targets', 'skills', 'ownSkills', 'catalogVersion', 'tier', 'optOuts'];
19
+
20
+ export function readManifest(cwd = process.cwd()) {
21
+ const file = manifestPath(cwd);
22
+ if (!existsSync(file)) return null;
23
+ let raw;
24
+ // An unreadable manifest is NOT the same as an absent one. Absent means "not adopted
25
+ // yet" and is normal; corrupt means the file that governs this project cannot be
26
+ // trusted, and silently treating it as absent would then overwrite it on the next
27
+ // install. Say it and stop.
28
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
29
+ throw new Error(`${MANIFEST} exists but is not readable JSON — fix or delete it; rsc will not overwrite it`);
30
+ }
31
+ return {
32
+ version: raw.version ?? 1,
33
+ targets: raw.targets || [],
34
+ skills: raw.skills || [],
35
+ ownSkills: raw.ownSkills || [],
36
+ catalogVersion: raw.catalogVersion ?? null,
37
+ tier: raw.tier ?? null,
38
+ optOuts: raw.optOuts || [],
39
+ };
40
+ }
41
+
42
+ // Stable output: fixed key order, arrays one entry per line, order as given. Rewriting an
43
+ // unchanged manifest must produce identical bytes, or every install shows up as a diff.
44
+ export function writeManifest(cwd, manifest) {
45
+ const out = {};
46
+ for (const k of KEYS) if (manifest[k] !== undefined) out[k] = manifest[k];
47
+ out.version ??= 1;
48
+ const ordered = {};
49
+ for (const k of KEYS) if (out[k] !== undefined) ordered[k] = out[k];
50
+ const file = manifestPath(cwd);
51
+ writeFileSync(file, JSON.stringify(ordered, null, 2) + '\n');
52
+ return file;
53
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { targetPaths, installedTargets, detectTarget, unwireHook } from '../../targets/index.js';
5
+ import { readState } from './state.js';
6
+ import { readManifest } from './manifest-file.js';
7
+ import { createBackup } from './backups.js';
8
+ import { countHookEntries } from '../doctor.js';
9
+ import { applyInstall, recordInManifest, managedPathsForInstall } from '../install-apply.js';
10
+
11
+ // Fixing a harness that came from an earlier version.
12
+ //
13
+ // The other two deliveries fix the future and fix nobody: everyone who built a harness on
14
+ // 0.1 or 1.0 wakes up the day after with exactly what they had, and does not know it. This
15
+ // is the one command for them — no flags, no need to know what is wrong.
16
+ //
17
+ // EVERY finding is born with its class, and the executor only reads that field. It never
18
+ // decides:
19
+ //
20
+ // restore — put the harness back to what was ALREADY declared. Nobody decides anything,
21
+ // so it happens on its own, after a recoverable copy.
22
+ // change — alter something a person or a team chose. Always asked, one by one.
23
+ //
24
+ // Putting the boundary in the data instead of the executor means it can be tested without
25
+ // running anything, and it cannot drift apart between branches.
26
+
27
+ const finding = (id, cls, summary, action) => ({ id, class: cls, summary, action });
28
+
29
+ export function diagnose({ cwd = process.cwd(), target, home = homedir(), invoked = false } = {}) {
30
+ const paths = targetPaths(target, home, cwd);
31
+ const state = readState(paths.stateFile);
32
+ const installed = Object.keys(state.skills || {});
33
+ const manifest = readManifest(cwd);
34
+ const out = [];
35
+
36
+ // Nothing of ours here at all: say so and touch nothing. Running this in the wrong
37
+ // folder must be harmless.
38
+ if (!installed.length && !manifest && !existsSync(join(cwd, '.rsc'))) return out;
39
+
40
+ // The 0.1 layout Claude Code never discovered. writeSkill() sweeps it, but only when
41
+ // that skill is reinstalled — so anyone who never reinstalled still carries it.
42
+ if (existsSync(join(paths.root, 'rsc'))) {
43
+ out.push(finding('nested-layout', 'restore',
44
+ 'A .claude/skills/rsc/ directory from the 0.1 layout is still here; no assistant ever read it.',
45
+ 'It gets removed. Nothing that works today lives in it.'));
46
+ }
47
+
48
+ // Repeated wiring. On Windows the needle never matched, so every install ADDED a hook
49
+ // instead of replacing it: there were real machines running each hook four times.
50
+ const repeated = duplicatedHooks(paths.hookTarget);
51
+ if (repeated.length) {
52
+ out.push(finding('duplicate-hooks', 'restore',
53
+ `${repeated.join(', ')} wired more than once, so those hooks run several times per event.`,
54
+ 'The extra copies are removed, leaving one of each.'));
55
+ }
56
+
57
+ // A clone: the declaration travelled, the materialised skills did not.
58
+ const dangling = installed.filter((id) => !existsSync(paths.skillDir(id)))
59
+ .concat((manifest?.skills || []).filter((id) => !existsSync(paths.skillDir(id))));
60
+ if (dangling.length) {
61
+ out.push(finding('dangling-links', 'restore',
62
+ `${[...new Set(dangling)].join(', ')} are declared but not on disk — what a fresh clone looks like.`,
63
+ 'They get rebuilt from the catalog at the version the manifest pins.'));
64
+ }
65
+
66
+ // Writing the manifest records something already true, which sounds like a restoration.
67
+ // But it makes a new committable file appear in someone's root, and that surprises —
68
+ // and surprising people is what started all of this. So: automatic when they typed the
69
+ // command (they already said yes), asked when we are the ones who noticed.
70
+ if (!manifest && installed.length) {
71
+ out.push(finding('no-manifest', invoked ? 'restore' : 'change',
72
+ 'This harness predates .rsc.json, so it cannot be shared by git yet.',
73
+ 'A manifest is written from what is already installed here.'));
74
+ }
75
+
76
+ // Installed somewhere the project does not look like. This is #249: `harness` writes an
77
+ // AGENTS.md, detection read it as codex, and people ended up wired to an assistant they
78
+ // never chose. Moving is a change — it is where the harness lives.
79
+ const others = installedTargets(cwd).filter((t) => t !== target);
80
+ const looksLike = detectTarget(cwd);
81
+ if (!others.length && installed.length && looksLike !== target && !manifest) {
82
+ out.push(finding('wrong-target', 'change',
83
+ `The harness is installed for ${target}, but this project looks like ${looksLike}.`,
84
+ `It moves to ${looksLike}, and the old wiring — including the block in the root file — is removed.`),
85
+ );
86
+ out[out.length - 1].to = looksLike;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // Remove the old wiring's skills. The list comes from the target's OWN state, which by
92
+ // construction holds only what rsc installed — so "we never touch what we did not write"
93
+ // is a property of the input here, not a check that could be forgotten or drift.
94
+ //
95
+ // It was a check, once, sitting on a list that mixed state and manifest ids. A mutant that
96
+ // deleted the check killed no test, and chasing that revealed the branch was unreachable
97
+ // anyway. A guard that cannot be seen to fail is worse than no guard (principle 2), so it
98
+ // became structure instead.
99
+ function removeManagedSkills(paths) {
100
+ for (const id of Object.keys(readState(paths.stateFile).skills || {})) {
101
+ rmSync(paths.skillDir(id), { recursive: true, force: true });
102
+ }
103
+ rmSync(paths.stateFile, { force: true });
104
+ }
105
+
106
+ function duplicatedHooks(hookTarget) {
107
+ let settings;
108
+ try { settings = JSON.parse(readFileSync(hookTarget, 'utf8')); } catch { return []; }
109
+ const { perEvent } = countHookEntries(settings);
110
+ const dup = [];
111
+ for (const [event, ids] of Object.entries(perEvent)) {
112
+ for (const [id, n] of Object.entries(ids)) if (n > 1) dup.push(`${event}/${id}`);
113
+ }
114
+ return dup;
115
+ }
116
+
117
+ export async function repair({ cwd = process.cwd(), target, home = homedir(), dryRun = false, invoked = false, accept } = {}) {
118
+ const found = diagnose({ cwd, target, home, invoked });
119
+ const restores = found.filter((f) => f.class === 'restore');
120
+ const changes = found.filter((f) => f.class === 'change');
121
+
122
+ // Anything the person has to decide is only applied if they actually said yes. With
123
+ // nobody to ask, it stays pending — and the restorations still get done, because they
124
+ // never depended on that decision.
125
+ const okChanges = [];
126
+ for (const c of changes) if (accept && await accept(c)) okChanges.push(c);
127
+ const todo = [...restores, ...okChanges];
128
+ if (!todo.length || dryRun) {
129
+ return { applied: todo, pending: changes.filter((c) => !okChanges.includes(c)), backup: null };
130
+ }
131
+
132
+ const paths = targetPaths(target, home, cwd);
133
+ const ids = [...new Set([...Object.keys(readState(paths.stateFile).skills || {}), ...(readManifest(cwd)?.skills || [])])];
134
+ const backup = createBackup({
135
+ cwd, operation: 'repair', target,
136
+ paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
137
+ });
138
+
139
+ for (const f of todo) {
140
+ if (f.id === 'nested-layout') rmSync(join(paths.root, 'rsc'), { recursive: true, force: true });
141
+ if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' }); }
142
+ if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' });
143
+ if (f.id === 'no-manifest') recordInManifest({ cwd, target, skillIds: ids });
144
+ if (f.id === 'wrong-target') {
145
+ // Install where the project actually points, THEN take the old wiring down — in that
146
+ // order, so a failure halfway leaves a working harness rather than none. Taking the
147
+ // block out of the old file gives it back byte-identical: in #249 that file was the
148
+ // project's hand-written constitution, and it had already been written into once.
149
+ await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair' });
150
+ unwireHook(target, paths);
151
+ removeManagedSkills(paths);
152
+ recordInManifest({ cwd, target: f.to, skillIds: ids, dropTarget: target });
153
+ }
154
+ }
155
+ return { applied: todo, pending: changes.filter((c) => !okChanges.includes(c)), backup };
156
+ }
package/scripts/lib/ui.js CHANGED
@@ -29,6 +29,11 @@ function interactive() {
29
29
  return Boolean(stdin.isTTY && stdout.isTTY);
30
30
  }
31
31
 
32
+ // The one criterion for "is a human here to answer". Exported so target resolution
33
+ // asks the same question the TUI already asks — two criteria for one question is how
34
+ // a terminal run and a CI run drift apart.
35
+ export const isInteractive = interactive;
36
+
32
37
  const C = {
33
38
  dim: (s) => `\x1b[2m${s}\x1b[22m`,
34
39
  bold: (s) => `\x1b[1m${s}\x1b[22m`,
package/scripts/rsc.js CHANGED
@@ -1,22 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadManifest, skillsForProfile } from './lib/manifest.js';
3
- import { detectTarget, TARGETS } from '../targets/index.js';
3
+ import { detectTarget, resolveTargets, TARGETS } from '../targets/index.js';
4
4
  import { detectRepo } from './detect-repo.js';
5
5
  import { rank } from './consult.js';
6
6
  import { expandRecommends, toOutcomes, hasOutcome } from './lib/recommend.js';
7
- import { applyInstall, listInstalled, uninstall, syncInstalled, purge } from './install-apply.js';
7
+ import { applyInstall, listInstalled, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
8
8
  import { doctor } from './doctor.js';
9
- import { say, select, pickFrom, banner, confirm } from './lib/ui.js';
9
+ import { say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
10
10
  import { refreshRegistry, registryStatus } from './lib/registry.js';
11
11
  import { audit, writeAuditReport } from './audit.js';
12
12
  import { DOMAINS } from './lib/domains.js';
13
13
  import { listBackups, restoreBackup } from './lib/backups.js';
14
14
  import { runUpgrade } from './lib/upgrade.js';
15
+ import { diagnose, repair } from './lib/repair.js';
15
16
  import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-floor.js';
16
17
 
17
18
  const argv = process.argv.slice(2);
18
19
  const cmd = argv[0];
19
20
 
21
+ const targetLabel = (id) => TARGETS.find((t) => t.id === id)?.label || id;
22
+
23
+ // Commands that act ON one assistant. Only these stop when two are installed and no
24
+ // flag says which: `purge` sweeps every target by design, and the catalog/consult
25
+ // side barely touches one, so blocking them would be a regression, not a safeguard.
26
+ const NEEDS_TARGET = new Set([
27
+ 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
28
+ ]);
29
+
20
30
  function flag(name) {
21
31
  const i = argv.indexOf(`--${name}`);
22
32
  return i >= 0 ? (argv[i + 1] || true) : undefined;
@@ -157,7 +167,7 @@ function printContextBudget(b) {
157
167
  say('════════════════════════════════════════════════\n');
158
168
  }
159
169
 
160
- async function wizard() {
170
+ async function wizard(flagTargets) {
161
171
  const m = loadManifest();
162
172
  await banner(m.counts.skills);
163
173
  say(' the skill catalog for your assistant (Claude Code · Codex · Cursor · Gemini · Antigravity)\n');
@@ -190,7 +200,7 @@ async function wizard() {
190
200
  continue;
191
201
  }
192
202
 
193
- const targets = await selectAgents();
203
+ const targets = flagTargets || await selectAgents();
194
204
  if (targets === null) continue; // esc in the assistant picker → back to menu
195
205
  say(`\nI'll install ${ids.length} skills for: ${targets.join(', ')}`);
196
206
  say(' ' + ids.join(', '));
@@ -209,16 +219,54 @@ async function wizard() {
209
219
  }
210
220
  }
211
221
 
222
+ // Installing replaces whatever sits where a skill goes. If that is something the user
223
+ // wrote by hand, it is the one thing an install can destroy — so it is named first and
224
+ // they decide. Silence here is what made someone lose three months of work with a backup
225
+ // they never knew to restore.
226
+ async function guardCollisions(targets, ids) {
227
+ const hit = [...new Set(targets.flatMap((t) => collisions({ target: t, skillIds: ids })))];
228
+ if (!hit.length) return true;
229
+ say(`\n⚠️ These are already here and rsc did not put them — they look like your own work:`);
230
+ for (const id of hit) say(` ${id}`);
231
+ say(' Installing would replace them. A backup is kept, but you would have to know to restore it.');
232
+ if (!isInteractive()) {
233
+ console.error('rsc: refusing to overwrite hand-written skills. Rename them, or re-run with --force.');
234
+ process.exitCode = 1;
235
+ return false;
236
+ }
237
+ return confirm('Replace them anyway?');
238
+ }
239
+
212
240
  async function main() {
213
- // --target accepts one id or a comma list (e.g. --target claude,codex). No flag → detect.
241
+ // One resolution for every command, doctor included: `doctor` used to resolve on its
242
+ // own and could report a different assistant than the one just installed into.
243
+ // --target accepts one id or a comma list (e.g. --target claude,codex).
214
244
  const f = flag('target');
215
- const targets = typeof f === 'string'
216
- ? f.split(',').map((s) => s.trim()).filter(Boolean)
217
- : [detectTarget()];
245
+ const resolved = resolveTargets({ flagValue: typeof f === 'string' ? f : undefined });
246
+ let targets = resolved.ids;
247
+ if (resolved.ambiguous && NEEDS_TARGET.has(cmd)) {
248
+ // Two installations, no flag: the whole point of this release is that we do NOT pick.
249
+ if (isInteractive()) {
250
+ const picked = await pickFrom(
251
+ 'Two assistants are installed here. Which one do you mean?',
252
+ resolved.ambiguous.map((id) => ({ id, label: `${targetLabel(id)} (${listInstalled({ target: id }).length} skills)` })),
253
+ );
254
+ if (!picked || !picked.length) return void say('Cancelled — nothing was touched.');
255
+ targets = picked;
256
+ } else {
257
+ console.error(
258
+ `rsc: ${resolved.ambiguous.join(' and ')} are both installed here, so I will not guess.\n` +
259
+ ` Say which one: npx @ericrisco/rsc ${cmd} --target ${resolved.ambiguous[0]}`,
260
+ );
261
+ process.exitCode = 1;
262
+ return;
263
+ }
264
+ }
265
+ if (!targets.length) targets = [detectTarget()];
218
266
  const target = targets[0];
219
267
  switch (cmd) {
220
268
  case undefined:
221
- return wizard();
269
+ return wizard(f ? targets : null);
222
270
  case 'add': {
223
271
  // Positional args = skill ids; skip flags and any flag value (the token after a --flag).
224
272
  const requested = [];
@@ -227,6 +275,7 @@ async function main() {
227
275
  requested.push(argv[i]);
228
276
  }
229
277
  const ids = withDefaultSkillFloor(requested);
278
+ if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
230
279
  for (const t of targets) await applyInstall({ skillIds: ids, target: t });
231
280
  say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
232
281
  return void say(' ↻ Reload/restart your assistant so the new skill activates.');
@@ -236,6 +285,7 @@ async function main() {
236
285
  const without = argv.filter((a, i) => argv[i - 1] === '--without');
237
286
  let ids = skillsForProfile(loadManifest(), profile);
238
287
  ids = withDefaultSkillFloor(ids).filter((id) => !without.includes(id));
288
+ if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
239
289
  for (const t of targets) await applyInstall({ skillIds: ids, target: t });
240
290
  say(`✅ Profile '${profile}' installed for ${targets.join(', ')} (${ids.length} skills)`);
241
291
  printAgentHandoff();
@@ -577,11 +627,34 @@ async function main() {
577
627
  const removed = await uninstall({ skillIds: ids, target, dryRun: dry });
578
628
  return void say((dry ? 'Would remove:\n' : 'Removed:\n') + (removed.join('\n') || '(nothing)'));
579
629
  }
630
+ case 'repair': {
631
+ // One command, no flags, for someone who does not know what is wrong. Restorations
632
+ // happen on their own; anything that changes a decision is asked, one by one.
633
+ const dry = argv.includes('--dry-run');
634
+ const yes = argv.includes('--yes');
635
+ const found = diagnose({ target, invoked: true });
636
+ if (!found.length) return void say('Nothing to repair — this harness is healthy.');
637
+ say(`\nFound ${found.length} thing(s):\n`);
638
+ for (const f of found) say(` [${f.class === 'restore' ? 'fix' : 'ask'}] ${f.summary}\n → ${f.action}`);
639
+ const accept = async (f) => {
640
+ if (yes) return true;
641
+ if (!isInteractive()) return false;
642
+ return confirm(`\nApply: ${f.summary}`);
643
+ };
644
+ const r = await repair({ target, dryRun: dry, invoked: true, accept });
645
+ say('');
646
+ if (dry) return void say(`Dry run — nothing written. Would apply ${r.applied.length}.`);
647
+ say(`Repaired ${r.applied.length} thing(s).${r.backup ? ' A copy of the previous state was kept.' : ''}`);
648
+ for (const p of r.pending) say(` still pending (your call): ${p.summary}\n → ${p.action}`);
649
+ return void say(' ↻ Reload/restart your assistant.');
650
+ }
580
651
  case 'purge':
581
652
  return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
582
653
  default:
583
654
  say(`rsc: unknown command '${cmd}'.`);
584
- say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | uninstall <id> | purge');
655
+ say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
656
+ say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
657
+ say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
585
658
  process.exitCode = 1;
586
659
  }
587
660
  }
@@ -11,9 +11,9 @@ origin: risco
11
11
 
12
12
  **Hand-off — the visual system vs the craft of motion.** This skill owns the visual system and page
13
13
  composition, and sets motion *intent + budget* only. The **implementation** of an animation is
14
- `../animate/SKILL.md`'s; reviewing motion is `../review-animations/SKILL.md`'s; gesture physics and
15
- translucent materials are `../apple-design/SKILL.md`'s. When a frontend request could belong to
16
- several of those, `../design-eng/SKILL.md` is the umbrella that routes it. The motion reference in
14
+ `../motion-craft/SKILL.md`'s; how a piece of interface should be BUILT — boundaries, state, the
15
+ loading and empty and error states — is `../ui-engineering/SKILL.md`'s. When a request could belong to
16
+ several of those, start from the one that owns the decision, not the one that owns the file. The motion reference in
17
17
  this skill defers its mechanics to them rather than restating them.
18
18
 
19
19
 
@@ -42,7 +42,7 @@ not settle whether the page says anything.
42
42
 
43
43
  ## Where to browse for component ideas
44
44
 
45
- When a surface needs an idea rather than a primitive, `../../pick-ui-library/references/component-galleries.md`
45
+ When a surface needs an idea rather than a primitive, a gallery of shipped products
46
46
  names where to go — Aceternity for marketing effects, 21st.dev for breadth, React Bits for tunable
47
47
  animation — and what each is good for. Take the technique and rebuild it in this project's tokens;
48
48
  check the brand study for what it refuses first, because a component that contradicts a stated ban