@ericrisco/rsc 0.1.22 → 0.1.24

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 CHANGED
@@ -150,6 +150,11 @@ rsc consult "I want to launch a SaaS" # recommend only, no install
150
150
  rsc registry refresh # write .rsc/skill-registry.{json,md}
151
151
  rsc list # what rsc has installed
152
152
  rsc doctor # health check (state, hook, counts)
153
+ rsc sync --target claude,codex # refresh managed skills/hooks from the current package version
154
+ rsc backups # list project-local snapshots
155
+ rsc restore latest --dry-run # preview restoring the newest snapshot
156
+ rsc restore <snapshot-id> # restore a project-local snapshot
157
+ rsc upgrade --dry-run # show npm upgrade + sync commands
153
158
  rsc uninstall postgresdb --dry-run # preview a removal
154
159
  ```
155
160
 
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.22",
2
+ "version": "0.1.24",
3
3
  "counts": {
4
4
  "skills": 231
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -1,18 +1,27 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
2
3
  import { targetPaths } from '../targets/index.js';
3
4
  import { readState } from './lib/state.js';
4
5
  import { loadManifest } from './lib/manifest.js';
6
+ import { listBackups } from './lib/backups.js';
5
7
 
6
8
  export function doctor({ target, home, cwd }) {
9
+ const root = cwd || process.cwd();
7
10
  const paths = targetPaths(target, home, cwd);
8
11
  const state = readState(paths.stateFile);
9
12
  const manifest = loadManifest();
13
+ const backups = listBackups({ cwd: root });
10
14
  const report = {
11
15
  target,
12
16
  installed: Object.keys(state.skills),
13
17
  missing: [],
14
18
  hookWired: existsSync(paths.hookTarget),
15
19
  manifestSkills: manifest.counts.skills,
20
+ backups: {
21
+ exists: existsSync(join(root, '.rsc', 'backups')),
22
+ count: backups.length,
23
+ latest: backups[0]?.id || null,
24
+ },
16
25
  };
17
26
  for (const [id, e] of Object.entries(state.skills)) {
18
27
  for (const f of e.files) if (!existsSync(f)) report.missing.push(`${id}:${f}`);
@@ -5,6 +5,7 @@ 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 { readState, writeState } from './lib/state.js';
8
+ import { createBackup } from './lib/backups.js';
8
9
 
9
10
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
@@ -32,10 +33,37 @@ function ensureBase(id, cwd, refresh) {
32
33
  return dest;
33
34
  }
34
35
 
35
- export async function applyInstall({ skillIds, target, home, cwd = process.cwd() }) {
36
+ function generatedHookFiles({ target, cwd }) {
37
+ if (target !== 'claude') return [];
38
+ return [
39
+ join(cwd, '.rsc', 'session-start.mjs'),
40
+ join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
41
+ join(cwd, '.rsc', 'ship-guard.mjs'),
42
+ join(cwd, '.rsc', 'danger-guard.mjs'),
43
+ ];
44
+ }
45
+
46
+ function managedPathsForInstall({ skillIds, target, home, cwd }) {
47
+ const paths = targetPaths(target, home, cwd);
48
+ const plan = planInstall({ skillIds, target, home, cwd });
49
+ const out = [paths.stateFile, versionFile(cwd)];
50
+ for (const step of plan) {
51
+ if (step.kind === 'skill') {
52
+ out.push(step.to, baseDir(step.id, cwd));
53
+ } else if (step.kind === 'hook') {
54
+ out.push(step.to, ...generatedHookFiles({ target, cwd }));
55
+ }
56
+ }
57
+ return [...new Set(out)];
58
+ }
59
+
60
+ export async function applyInstall({ skillIds, target, home, cwd = process.cwd(), operation = 'install', dryRun = false }) {
36
61
  const paths = targetPaths(target, home, cwd);
37
62
  const plan = planInstall({ skillIds, target, home, cwd });
63
+ const managedPaths = managedPathsForInstall({ skillIds, target, home, cwd });
64
+ if (dryRun) return { dryRun: true, skills: skillIds, paths: managedPaths };
38
65
  const state = readState(paths.stateFile);
66
+ const backup = createBackup({ cwd, operation, target, paths: managedPaths, cliVersion: CLI_VERSION });
39
67
  // Refresh bases when installing a different version than they were materialized at
40
68
  // (or a pre-versioning install where the marker is absent). Same version → no-op.
41
69
  const refresh = readBaseVersion(cwd) !== CLI_VERSION;
@@ -52,7 +80,7 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
52
80
  writeState(paths.stateFile, state);
53
81
  mkdirSync(dirname(versionFile(cwd)), { recursive: true });
54
82
  writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
55
- return state;
83
+ return { ...state, backup };
56
84
  }
57
85
 
58
86
  export function listInstalled({ target, home, cwd = process.cwd() }) {
@@ -64,23 +92,52 @@ export async function uninstall({ skillIds, target, home, cwd = process.cwd(), d
64
92
  const paths = targetPaths(target, home, cwd);
65
93
  const state = readState(paths.stateFile);
66
94
  const removed = [];
95
+ const managedPaths = [paths.stateFile];
67
96
  for (const id of skillIds) {
68
97
  const entry = state.skills[id];
69
98
  if (!entry) continue;
70
99
  for (const f of entry.files) {
100
+ managedPaths.push(f);
71
101
  removed.push(f);
72
- if (!dryRun && existsSync(f)) rmSync(f, { recursive: true, force: true });
73
102
  }
74
- if (!dryRun) delete state.skills[id];
75
103
  }
76
- if (!dryRun) writeState(paths.stateFile, state);
104
+ if (!removed.length) return removed;
105
+ if (dryRun) return removed;
106
+ createBackup({ cwd, operation: 'uninstall', target, paths: managedPaths, cliVersion: CLI_VERSION });
107
+ for (const id of skillIds) {
108
+ const entry = state.skills[id];
109
+ if (!entry) continue;
110
+ for (const f of entry.files) {
111
+ if (existsSync(f)) rmSync(f, { recursive: true, force: true });
112
+ }
113
+ delete state.skills[id];
114
+ }
115
+ writeState(paths.stateFile, state);
77
116
  return removed;
78
117
  }
79
118
 
119
+ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun = false }) {
120
+ const paths = targetPaths(target, home, cwd);
121
+ const state = readState(paths.stateFile);
122
+ const ids = Object.keys(state.skills || {});
123
+ if (!ids.length) return dryRun ? { dryRun: true, synced: [], paths: [] } : { synced: [], backup: null };
124
+ if (dryRun) {
125
+ return {
126
+ dryRun: true,
127
+ synced: ids,
128
+ paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
129
+ };
130
+ }
131
+ const nextState = await applyInstall({ skillIds: ids, target, home, cwd, operation: 'sync' });
132
+ return { synced: ids, backup: nextState.backup };
133
+ }
134
+
80
135
  // Remove EVERYTHING rsc put in this project: installed skills across all targets,
81
136
  // the wired hooks (settings.json entries / AGENTS-blocks / cursor rules), and the
82
137
  // shared `.rsc/` (base + hook scripts + version marker). `02-DOCS/` is the user's
83
138
  // own knowledge — kept unless `withDocs` is set. Returns the paths touched.
139
+ // Note: backups live under `.rsc/backups/`, which this removes — so purge does not
140
+ // snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
84
141
  export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRun = false } = {}) {
85
142
  const removed = [];
86
143
  const drop = (p, recursive = false) => {
@@ -0,0 +1,154 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ readlinkSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
14
+
15
+ const SCHEMA_VERSION = 1;
16
+
17
+ export function backupsDir(cwd = process.cwd()) {
18
+ return join(cwd, '.rsc', 'backups');
19
+ }
20
+
21
+ export function createBackup({ cwd = process.cwd(), operation, target, paths, cliVersion, now = new Date() }) {
22
+ const uniquePaths = [...new Set((paths || []).filter(Boolean))];
23
+ const id = uniqueSnapshotId({ cwd, now, operation, target });
24
+ const root = join(backupsDir(cwd), id);
25
+ const filesRoot = join(root, 'files');
26
+ mkdirSync(filesRoot, { recursive: true });
27
+
28
+ const entries = uniquePaths.map((absPath) => snapshotEntry({ cwd, root, absPath }));
29
+ const manifest = {
30
+ schemaVersion: SCHEMA_VERSION,
31
+ id,
32
+ createdAt: now.toISOString(),
33
+ operation,
34
+ target,
35
+ cwd,
36
+ cliVersion,
37
+ entries,
38
+ };
39
+ writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
40
+ return manifest;
41
+ }
42
+
43
+ export function listBackups({ cwd = process.cwd() } = {}) {
44
+ const dir = backupsDir(cwd);
45
+ if (!existsSync(dir)) return [];
46
+ return readdirSync(dir)
47
+ .map((id) => readManifest({ cwd, id }))
48
+ .filter(Boolean)
49
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
50
+ }
51
+
52
+ export function restoreBackup({ cwd = process.cwd(), id, dryRun = false }) {
53
+ const snapshot = resolveSnapshot({ cwd, id });
54
+ const changed = [];
55
+ for (const entry of snapshot.entries) {
56
+ const absPath = safeJoin(cwd, entry.path);
57
+ changed.push(absPath);
58
+ if (dryRun) continue;
59
+ restoreEntry({ cwd, snapshot, entry, absPath });
60
+ }
61
+ return { snapshot, changed };
62
+ }
63
+
64
+ function snapshotEntry({ cwd, root, absPath }) {
65
+ const rel = safeRelative(cwd, absPath);
66
+ if (!existsSync(absPath)) return { path: rel, existed: false, kind: 'missing' };
67
+
68
+ const stat = lstatSync(absPath);
69
+ if (stat.isSymbolicLink()) {
70
+ return { path: rel, existed: true, kind: 'symlink', linkTarget: readlinkSync(absPath) };
71
+ }
72
+
73
+ const contentPath = join('files', rel);
74
+ const contentAbs = join(root, contentPath);
75
+ mkdirSync(dirname(contentAbs), { recursive: true });
76
+ if (stat.isDirectory()) {
77
+ cpSync(absPath, contentAbs, { recursive: true });
78
+ return { path: rel, existed: true, kind: 'dir', contentPath };
79
+ }
80
+
81
+ cpSync(absPath, contentAbs);
82
+ return { path: rel, existed: true, kind: 'file', contentPath };
83
+ }
84
+
85
+ function restoreEntry({ cwd, snapshot, entry, absPath }) {
86
+ if (!entry.existed) {
87
+ rmSync(absPath, { recursive: true, force: true });
88
+ return;
89
+ }
90
+
91
+ rmSync(absPath, { recursive: true, force: true });
92
+ mkdirSync(dirname(absPath), { recursive: true });
93
+ if (entry.kind === 'symlink') {
94
+ symlinkSync(entry.linkTarget, absPath, process.platform === 'win32' ? 'junction' : undefined);
95
+ return;
96
+ }
97
+
98
+ const contentAbs = join(backupsDir(cwd), snapshot.id, entry.contentPath);
99
+ cpSync(contentAbs, absPath, { recursive: entry.kind === 'dir' });
100
+ }
101
+
102
+ function resolveSnapshot({ cwd, id }) {
103
+ if (!id) throw new Error('restore requires a snapshot id or latest');
104
+ if (id === 'latest') {
105
+ const latest = listBackups({ cwd })[0];
106
+ if (!latest) throw new Error('no backups found');
107
+ return latest;
108
+ }
109
+
110
+ const manifest = readManifest({ cwd, id });
111
+ if (!manifest) throw new Error(`backup not found: ${id}`);
112
+ return manifest;
113
+ }
114
+
115
+ function readManifest({ cwd, id }) {
116
+ const path = join(backupsDir(cwd), id, 'manifest.json');
117
+ if (!existsSync(path)) return undefined;
118
+ return JSON.parse(readFileSync(path, 'utf8'));
119
+ }
120
+
121
+ function uniqueSnapshotId({ cwd, now, operation, target }) {
122
+ const base = snapshotId({ now, operation, target });
123
+ let id = base;
124
+ let counter = 2;
125
+ while (existsSync(join(backupsDir(cwd), id))) {
126
+ id = `${base}-${counter}`;
127
+ counter += 1;
128
+ }
129
+ return id;
130
+ }
131
+
132
+ function snapshotId({ now, operation, target }) {
133
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-');
134
+ return `${stamp}-${safeId(operation)}-${safeId(target || 'all')}`;
135
+ }
136
+
137
+ function safeId(value) {
138
+ return String(value).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
139
+ }
140
+
141
+ function safeRelative(cwd, absPath) {
142
+ const rel = relative(cwd, absPath);
143
+ if (!rel || rel.startsWith('..') || rel.split(sep).includes('..')) {
144
+ throw new Error(`path is outside project root: ${absPath}`);
145
+ }
146
+ return rel.split(sep).join('/');
147
+ }
148
+
149
+ function safeJoin(cwd, relPath) {
150
+ if (!relPath || isAbsolute(relPath) || relPath.split('/').includes('..')) {
151
+ throw new Error(`path is outside project root: ${relPath}`);
152
+ }
153
+ return join(cwd, ...relPath.split('/'));
154
+ }
@@ -0,0 +1,18 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export function upgradePlan({ targets = [] } = {}) {
4
+ const targetArg = targets.length ? targets.join(',') : '<target>';
5
+ return {
6
+ installCommand: 'npm install -g @ericrisco/rsc@latest',
7
+ syncCommand: `rsc sync --target ${targetArg}`,
8
+ };
9
+ }
10
+
11
+ export function runUpgrade({ targets = [], dryRun = false, global = false } = {}) {
12
+ const plan = upgradePlan({ targets });
13
+ if (dryRun || !global) return { ran: false, plan };
14
+
15
+ const result = spawnSync('npm', ['install', '-g', '@ericrisco/rsc@latest'], { stdio: 'inherit' });
16
+ if (result.status !== 0) throw new Error('npm global upgrade failed');
17
+ return { ran: true, plan };
18
+ }
package/scripts/rsc.js CHANGED
@@ -4,12 +4,14 @@ import { detectTarget, 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, purge } from './install-apply.js';
7
+ import { applyInstall, listInstalled, uninstall, syncInstalled, purge } from './install-apply.js';
8
8
  import { doctor } from './doctor.js';
9
9
  import { say, select, pickFrom, banner, confirm } 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
+ import { listBackups, restoreBackup } from './lib/backups.js';
14
+ import { runUpgrade } from './lib/upgrade.js';
13
15
 
14
16
  const argv = process.argv.slice(2);
15
17
  const cmd = argv[0];
@@ -200,6 +202,43 @@ async function main() {
200
202
  return void say(listInstalled({ target }).join('\n') || '(nothing installed)');
201
203
  case 'doctor':
202
204
  return void say(JSON.stringify(doctor({ target }), null, 2));
205
+ case 'sync': {
206
+ const dry = argv.includes('--dry-run');
207
+ for (const t of targets) {
208
+ const result = await syncInstalled({ target: t, dryRun: dry });
209
+ const verb = dry ? 'Would sync' : 'Synced';
210
+ say(`${verb} ${t}: ${result.synced.length ? result.synced.join(', ') : '(nothing to sync)'}`);
211
+ if (dry && result.paths?.length) {
212
+ for (const p of result.paths) say(` ${p}`);
213
+ }
214
+ }
215
+ return;
216
+ }
217
+ case 'backups': {
218
+ const backups = listBackups();
219
+ if (!backups.length) return void say('(no backups)');
220
+ for (const b of backups) {
221
+ say(`${b.id}\t${b.operation}\t${b.target}\t${b.entries.length} files\t${b.createdAt}`);
222
+ }
223
+ return;
224
+ }
225
+ case 'restore': {
226
+ const dry = argv.includes('--dry-run');
227
+ const id = argv.slice(1).find((a) => !a.startsWith('--'));
228
+ const result = restoreBackup({ id, dryRun: dry });
229
+ say(`${dry ? 'Would restore' : 'Restored'} ${result.snapshot.id}`);
230
+ for (const p of result.changed) say(` ${p}`);
231
+ return;
232
+ }
233
+ case 'upgrade': {
234
+ const dry = argv.includes('--dry-run');
235
+ const global = argv.includes('--global');
236
+ const result = runUpgrade({ targets, dryRun: dry, global });
237
+ if (result.ran) say('Upgraded global @ericrisco/rsc. Restart your shell if needed.');
238
+ else say(`${dry ? 'Would run' : 'Upgrade guide'}: ${result.plan.installCommand}`);
239
+ say(`After upgrade: ${result.plan.syncCommand}`);
240
+ return;
241
+ }
203
242
  case 'registry': {
204
243
  const sub = argv[1];
205
244
  if (sub === 'refresh') {
@@ -226,7 +265,7 @@ async function main() {
226
265
  return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
227
266
  default:
228
267
  say(`rsc: unknown command '${cmd}'.`);
229
- say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | audit | registry refresh | doctor | uninstall <id> | purge');
268
+ say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | audit | registry refresh | doctor | sync | backups | restore <id|latest> | upgrade | uninstall <id> | purge');
230
269
  }
231
270
  }
232
271