rahman-resources 0.4.3 → 0.8.0

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/bin/cli.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // rahman-resources — installer for the Rahman kitab.
3
3
  // Usage:
4
- // npx rahman-resources init <app-name> [--template <slug>] [--features a,b] [--skills x,y]
5
- // npx rahman-resources add <slug> [target-dir]
4
+ // npx rahman-resources init <app-name> [--template <slug>] [--features a,b] [--skills x,y] [--with-shadcn-all]
5
+ // npx rahman-resources add <slug> [target-dir] [--at root|preview] [--with-shadcn-all]
6
6
  // npx rahman-resources add-skill <slug> [target-dir]
7
- // npx rahman-resources list [layouts|recipes|features|skills]
7
+ // npx rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]
8
+ // npx rahman-resources list [layouts|recipes|features|skills|slices]
8
9
  // npx rahman-resources info <slug>
9
10
  // npx rahman-resources doctor
10
11
  // npx rahman-resources mcp # not implemented in CLI; install rahman-resources-mcp
11
12
 
12
13
  import { createRequire } from "node:module";
13
14
  import { spawn } from "node:child_process";
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
15
16
  import path from "node:path";
16
17
  import { fileURLToPath } from "node:url";
17
18
 
@@ -24,6 +25,7 @@ import {
24
25
  rrExists,
25
26
  validateRr,
26
27
  addFeature as rrAddFeature,
28
+ addSlice as rrAddSlice,
27
29
  addSkill as rrAddSkill,
28
30
  } from "../lib/rr.mjs";
29
31
  import { runPostInit } from "../lib/post-init.mjs";
@@ -37,7 +39,7 @@ const REPO = manifest.repo ?? "rahmanef63/resource-site";
37
39
  const BRANCH = manifest.branch ?? "main";
38
40
  const SKILLS_REPO = "anthropics/skills";
39
41
 
40
- const KINDS = /** @type {const} */ (["layout", "recipe", "feature"]);
42
+ const KINDS = /** @type {const} */ (["slice", "layout", "recipe", "feature"]);
41
43
 
42
44
  const [, , cmd, ...rest] = process.argv;
43
45
 
@@ -58,6 +60,12 @@ async function main() {
58
60
  return runAdd(rest);
59
61
  case "add-skill":
60
62
  return runAddSkill(rest);
63
+ case "scaffold-slice":
64
+ return runScaffoldSlice(rest);
65
+ case "lift":
66
+ return runLift(rest);
67
+ case "publish-slice":
68
+ return runPublishSlice(rest);
61
69
  case "list":
62
70
  case "ls":
63
71
  return runList(rest);
@@ -94,10 +102,13 @@ ${kleur.bold("rahman-resources")} — scaffold + install templates, recipes, fea
94
102
 
95
103
  ${kleur.bold("Usage:")}
96
104
  npx rahman-resources init <app-name> [--template <slug>] [--features a,b] [--skills x,y]
97
- [--no-install] [--with-shadcn-reinit]
98
- npx rahman-resources add <slug> [target-dir]
105
+ [--no-install] [--with-shadcn-reinit] [--with-shadcn-all]
106
+ npx rahman-resources add <slug> [target-dir] [--at root|preview] [--with-shadcn-all]
99
107
  npx rahman-resources add-skill <slug> [target-dir]
100
- npx rahman-resources list [layouts|recipes|features|skills]
108
+ npx rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]
109
+ npx rahman-resources lift <source>:<path> [--target <dir>] [--dry-run]
110
+ npx rahman-resources publish-slice <local-slice-dir> [--open-pr]
111
+ npx rahman-resources list [layouts|recipes|features|skills|slices]
101
112
  npx rahman-resources info <slug>
102
113
  npx rahman-resources doctor
103
114
  npx rahman-resources mcp
@@ -105,12 +116,21 @@ ${kleur.bold("Usage:")}
105
116
  ${kleur.bold("Init flags:")}
106
117
  --no-install skip 'npm install' step (faster scaffolds; you run it manually)
107
118
  --with-shadcn-reinit delete starter components.json + run 'npx shadcn init -y -d' (canonical shadcn flow)
119
+ --with-shadcn-all run 'npx shadcn add --all' instead of the per-template list
120
+ (heavy; ~50 components — use only if you'll customize beyond the template)
121
+
122
+ ${kleur.bold("Add flags:")}
123
+ --at root install template AT app/(public)/ + app/admin/ (recommended; rewrites
124
+ /preview/<slug> path constants in nav-config/robots/sitemap)
125
+ --at preview install template AT app/preview/<slug>/ (default — sandbox style)
126
+ --with-shadcn-all same as init flag
108
127
 
109
128
  ${kleur.bold("Examples:")}
110
129
  npx rahman-resources init my-app
111
130
  npx rahman-resources init my-app --template personal-brand-os --skills frontend-design,mcp-builder
112
131
  npx rahman-resources init my-app --no-install
113
- npx rahman-resources add personal-brand-os .
132
+ npx rahman-resources add personal-brand-os . --at root
133
+ npx rahman-resources add personal-brand-os . --with-shadcn-all
114
134
  npx rahman-resources add-skill webapp-testing
115
135
  npx rahman-resources list skills
116
136
  `);
@@ -144,7 +164,7 @@ function csv(s) {
144
164
 
145
165
  function findEntry(slug) {
146
166
  for (const kind of KINDS) {
147
- const list = manifest[kind + "s"];
167
+ const list = manifest[kind + "s"] ?? [];
148
168
  const e = list.find((x) => x.slug === slug);
149
169
  if (e) return { kind, entry: e };
150
170
  }
@@ -160,7 +180,7 @@ function findSkill(slug) {
160
180
  function runList([filter]) {
161
181
  const groups = filter
162
182
  ? [filter]
163
- : ["layouts", "recipes", "features", "skills"];
183
+ : ["slices", "layouts", "recipes", "features", "skills"];
164
184
  for (const g of groups) {
165
185
  if (g === "skills") {
166
186
  console.log(`\n${kleur.bold("SKILLS")} ${kleur.dim(`(${skillsInventory.skills.length})`)}\n`);
@@ -219,6 +239,22 @@ ${t.description}
219
239
  } else if (kind === "recipe") {
220
240
  console.log(`${kleur.bold("Files:")}`);
221
241
  console.log(t.files.map((f) => ` · ${f}`).join("\n"));
242
+ } else if (kind === "slice") {
243
+ console.log(`${kleur.bold("Pulls:")}`);
244
+ console.log(` · ${t.slicePath}`);
245
+ for (const cp of t.convexPaths ?? []) console.log(` · ${cp}`);
246
+ if (t.npm?.length) console.log(`\n${kleur.bold("npm:")}\n ${t.npm.join(" ")}`);
247
+ if (t.shadcn?.length) console.log(`\n${kleur.bold("shadcn:")}\n ${t.shadcn.join(" ")}`);
248
+ if (t.env?.length) {
249
+ console.log(`\n${kleur.bold("env:")}`);
250
+ for (const e of t.env) console.log(` · ${e.name} ${kleur.dim(`(${e.scope})`)}${e.required === false ? kleur.dim(" optional") : ""}`);
251
+ }
252
+ if (t.peers?.length) {
253
+ console.log(`\n${kleur.bold("peers:")}`);
254
+ for (const p of t.peers) console.log(` · ${p.slug} ${p.range}`);
255
+ }
256
+ if (t.providers?.length) console.log(`\n${kleur.bold("providers:")} ${t.providers.join(", ")}`);
257
+ console.log(`\n${kleur.bold("Install:")} ${kleur.cyan(`npx rahman-resources add ${t.slug}`)}`);
222
258
  }
223
259
  if (t.docsUrl) console.log(`\n${kleur.dim(`Docs: ${t.docsUrl}`)}`);
224
260
  console.log(`${kleur.dim(`Source: ${t.source ?? "—"}`)}\n`);
@@ -339,6 +375,20 @@ async function runInit(rest) {
339
375
  await pull(p, dest);
340
376
  console.log(kleur.green("ok"));
341
377
  }
378
+ if (!skipInstall) {
379
+ await maybeRunShadcnAdd(t, target, !!flags["with-shadcn-all"]);
380
+ } else {
381
+ console.log(kleur.dim(`\n (skipping shadcn add — --no-install)`));
382
+ }
383
+ // Strip the placeholder app/page.tsx — the template owns the root route.
384
+ const placeholder = path.join(target, "app", "page.tsx");
385
+ if (existsSync(placeholder)) {
386
+ try { rmSync(placeholder); console.log(` ${kleur.dim("removed placeholder")} app/page.tsx`); } catch {}
387
+ }
388
+ }
389
+
390
+ if (!skipInstall) {
391
+ await runOfflineConvexCodegen(target);
342
392
  }
343
393
 
344
394
  if (skills.length) {
@@ -366,7 +416,9 @@ function runShell(cmd, args, cwd) {
366
416
 
367
417
  // ─── add (template / feature / recipe) ────────────────────────────────────
368
418
 
369
- async function runAdd([slug, targetArg = "."]) {
419
+ async function runAdd(rest) {
420
+ const { positional, flags } = parseFlags(rest);
421
+ const [slug, targetArg = "."] = positional;
370
422
  if (!slug) {
371
423
  console.error(kleur.red("Missing slug."));
372
424
  printHelp();
@@ -377,16 +429,22 @@ async function runAdd([slug, targetArg = "."]) {
377
429
  const { kind, entry } = found;
378
430
  const target = path.resolve(process.cwd(), targetArg);
379
431
 
380
- if (kind === "layout") return addLayout(entry, target, targetArg);
432
+ if (kind === "slice") return runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
433
+ if (kind === "layout") return addLayout(entry, target, targetArg, flags);
381
434
  if (kind === "feature") return addFeature(entry, target, targetArg);
382
435
  if (kind === "recipe") return addRecipe(entry);
383
436
  }
384
437
 
385
- async function addLayout(t, target, targetArg) {
438
+ async function addLayout(t, target, targetArg, flags = {}) {
386
439
  console.log(kleur.bold(`\n→ Installing ${kleur.cyan(t.title)} into ${kleur.dim(target)}\n`));
387
440
  if (!t.pullPaths || t.pullPaths.length === 0) {
388
441
  throw new Error(`Layout "${t.slug}" has no valid pullPaths in manifest.`);
389
442
  }
443
+ const at = typeof flags.at === "string" ? flags.at : "preview";
444
+ if (!["root", "preview"].includes(at)) {
445
+ throw new Error(`--at must be "root" or "preview" (got "${at}").`);
446
+ }
447
+
390
448
  for (const p of t.pullPaths) {
391
449
  const dest = path.join(target, p);
392
450
  process.stdout.write(` pulling ${kleur.dim(p)} ... `);
@@ -405,6 +463,12 @@ async function addLayout(t, target, targetArg) {
405
463
  }
406
464
  }
407
465
 
466
+ await maybeRunShadcnAdd(t, target, !!flags["with-shadcn-all"]);
467
+
468
+ if (at === "root") {
469
+ promoteToRoot(t, target);
470
+ }
471
+
408
472
  if (rrExists(target)) {
409
473
  const rr = readRr(target);
410
474
  rr.template = { slug: t.slug, version: "main" };
@@ -415,6 +479,160 @@ async function addLayout(t, target, targetArg) {
415
479
  if (t.agentRecipe) console.log(`\n${kleur.bold("Next:")}\n${indent(t.agentRecipe, 2)}\n`);
416
480
  }
417
481
 
482
+ // ─── offline convex codegen ───────────────────────────────────────────────
483
+ //
484
+ // Self-hosted deploys (Dokploy etc) can't run codegen inside Docker because
485
+ // they have no Convex auth context. Postmortem 1.2: the workaround is to
486
+ // generate types locally with a dummy admin key + typecheck disabled, then
487
+ // commit `convex/_generated/` so the Docker build can typecheck against it.
488
+ async function runOfflineConvexCodegen(target) {
489
+ const convexDir = path.join(target, "convex");
490
+ if (!existsSync(convexDir)) return;
491
+ const generated = path.join(convexDir, "_generated");
492
+ if (existsSync(generated)) {
493
+ console.log(kleur.dim(`\n (convex/_generated already present — skipping codegen)`));
494
+ return;
495
+ }
496
+ console.log(kleur.bold(`\n→ Generating convex/_generated (offline)\n`));
497
+ try {
498
+ await new Promise((resolve, reject) => {
499
+ const ps = spawn(
500
+ "npx",
501
+ ["convex", "codegen", "--typecheck=disable"],
502
+ {
503
+ cwd: target,
504
+ stdio: "inherit",
505
+ shell: true,
506
+ env: {
507
+ ...process.env,
508
+ CONVEX_SELF_HOSTED_URL: "http://localhost:3210",
509
+ CONVEX_SELF_HOSTED_ADMIN_KEY: "x|x",
510
+ },
511
+ },
512
+ );
513
+ ps.on("error", reject);
514
+ ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`convex codegen exited ${code}`))));
515
+ });
516
+ } catch (err) {
517
+ console.log(kleur.yellow(` ⚠ codegen failed (${err.message}). Run later: CONVEX_SELF_HOSTED_URL=http://localhost:3210 CONVEX_SELF_HOSTED_ADMIN_KEY="x|x" npx convex codegen --typecheck=disable`));
518
+ }
519
+ }
520
+
521
+ // ─── shadcn auto-add ──────────────────────────────────────────────────────
522
+
523
+ async function maybeRunShadcnAdd(t, target, all) {
524
+ if (!hasPackageJson(target)) {
525
+ console.log(kleur.dim(`\n (skipping shadcn add — no package.json in target)`));
526
+ return;
527
+ }
528
+ const componentsJson = path.join(target, "components.json");
529
+ if (!existsSync(componentsJson)) {
530
+ console.log(kleur.yellow(`\n ⚠ components.json missing — run 'npx shadcn init' first, then re-add.`));
531
+ return;
532
+ }
533
+ const list = all ? ["--all"] : (t.shadcnComponents ?? []);
534
+ if (list.length === 0) {
535
+ console.log(kleur.dim(`\n (skipping shadcn add — no shadcnComponents declared for ${t.slug})`));
536
+ return;
537
+ }
538
+ console.log(kleur.bold(`\n→ Installing shadcn components ${all ? "(--all)" : `(${list.length})`}\n`));
539
+ console.log(kleur.dim(` ${list.join(" ")}\n`));
540
+ try {
541
+ await runShell("npx", ["shadcn@latest", "add", ...list, "--yes", "--overwrite"], target);
542
+ } catch (err) {
543
+ console.log(kleur.yellow(` ⚠ shadcn add failed (${err.message}). Run manually: npx shadcn@latest add ${list.join(" ")}`));
544
+ }
545
+ }
546
+
547
+ // ─── promote-to-root: move template files out of app/preview/<slug>/ into
548
+ // app/(public)/ + app/admin/, then rewrite hardcoded /preview/<slug>
549
+ // path constants in nav-config / robots / sitemap / site-config. ────────
550
+
551
+ function promoteToRoot(t, target) {
552
+ const previewDir = path.join(target, "app", "preview", t.slug);
553
+ if (!existsSync(previewDir)) {
554
+ console.log(kleur.dim(`\n (--at root: ${previewDir} not found — skipping promote)`));
555
+ return;
556
+ }
557
+ console.log(kleur.bold(`\n→ Promoting to app/(public)/ + app/admin/ (--at root)\n`));
558
+
559
+ const publicSrc = path.join(previewDir, "public");
560
+ const adminSrc = path.join(previewDir, "admin");
561
+ const publicDest = path.join(target, "app", "(public)");
562
+ const adminDest = path.join(target, "app", "admin");
563
+
564
+ if (existsSync(publicSrc)) {
565
+ mvTree(publicSrc, publicDest);
566
+ console.log(` ${kleur.green("+")} app/(public)/`);
567
+ }
568
+ if (existsSync(adminSrc)) {
569
+ mvTree(adminSrc, adminDest);
570
+ console.log(` ${kleur.green("+")} app/admin/`);
571
+ }
572
+ // Move robots/sitemap/og from app/preview/<slug>/ to app/
573
+ for (const stub of ["robots.ts", "sitemap.ts", "opengraph-image.tsx"]) {
574
+ const src = path.join(previewDir, stub);
575
+ if (existsSync(src)) {
576
+ const dest = path.join(target, "app", stub);
577
+ writeFileSync(dest, readFileSync(src, "utf8"));
578
+ console.log(` ${kleur.green("+")} app/${stub}`);
579
+ }
580
+ }
581
+
582
+ rewritePreviewPaths(target, t.slug);
583
+
584
+ // Best-effort cleanup of now-empty preview dir
585
+ try { rmSync(previewDir, { recursive: true, force: true }); } catch {}
586
+ }
587
+
588
+ function mvTree(src, dest) {
589
+ mkdirSync(dest, { recursive: true });
590
+ for (const entry of readdirSync(src)) {
591
+ const sFull = path.join(src, entry);
592
+ const dFull = path.join(dest, entry);
593
+ const stat = statSync(sFull);
594
+ if (stat.isDirectory()) {
595
+ mvTree(sFull, dFull);
596
+ } else {
597
+ writeFileSync(dFull, readFileSync(sFull, "utf8"));
598
+ }
599
+ }
600
+ }
601
+
602
+ // Rewrite hardcoded /preview/<slug>/{public,admin} → "" / "/admin" in known files
603
+ // (nav-config, robots, sitemap, site-config, plus any *Page.tsx that hardcodes them).
604
+ function rewritePreviewPaths(target, slug) {
605
+ const previewBase = `/preview/${slug}`;
606
+ const candidates = [
607
+ path.join(target, "app", "robots.ts"),
608
+ path.join(target, "app", "sitemap.ts"),
609
+ ];
610
+ // also components/templates/<base>/shared/{site-config,nav-config}.ts
611
+ const tplShared = path.join(target, "components", "templates");
612
+ if (existsSync(tplShared)) {
613
+ for (const baseDir of readdirSync(tplShared)) {
614
+ const sharedDir = path.join(tplShared, baseDir, "shared");
615
+ if (!existsSync(sharedDir)) continue;
616
+ for (const f of ["site-config.ts", "nav-config.ts"]) {
617
+ const p = path.join(sharedDir, f);
618
+ if (existsSync(p)) candidates.push(p);
619
+ }
620
+ }
621
+ }
622
+ for (const f of candidates) {
623
+ if (!existsSync(f)) continue;
624
+ const before = readFileSync(f, "utf8");
625
+ const after = before
626
+ .replaceAll(`${previewBase}/public`, "")
627
+ .replaceAll(`${previewBase}/admin`, "/admin")
628
+ .replaceAll(previewBase, "");
629
+ if (after !== before) {
630
+ writeFileSync(f, after);
631
+ console.log(` ${kleur.dim("rewrote")} ${path.relative(target, f)}`);
632
+ }
633
+ }
634
+ }
635
+
418
636
  async function addFeature(t, target, targetArg) {
419
637
  console.log(kleur.bold(`\n→ Adding feature ${kleur.cyan(t.title)} to ${kleur.dim(target)}\n`));
420
638
  if (!t.npmPackages || t.npmPackages.length === 0) {
@@ -497,7 +715,7 @@ async function installSkill(slug, target) {
497
715
 
498
716
  // ─── doctor ───────────────────────────────────────────────────────────────
499
717
 
500
- function runDoctor() {
718
+ function runDoctor(rest = []) {
501
719
  const target = process.cwd();
502
720
  if (!rrExists(target)) {
503
721
  console.log(kleur.yellow("⚠ No rr.json found in cwd. Run 'rahman-resources init <app>' first."));
@@ -505,16 +723,102 @@ function runDoctor() {
505
723
  }
506
724
  const rr = readRr(target);
507
725
  const issues = validateRr(rr);
508
- if (issues.length === 0) {
509
- console.log(kleur.green("✓ rr.json is valid."));
510
- console.log(` template: ${kleur.cyan(rr.template?.slug ?? "(none)")}`);
511
- console.log(` features: ${kleur.cyan(rr.features?.length ?? 0)}`);
512
- console.log(` skills: ${kleur.cyan(rr.skills?.length ?? 0)}`);
726
+ const sliceCheck = rest.includes("--slices");
727
+
728
+ if (issues.length > 0) {
729
+ console.log(kleur.red(`✖ rr.json has ${issues.length} issue(s):`));
730
+ for (const i of issues) console.log(` · ${i}`);
731
+ process.exit(1);
732
+ }
733
+
734
+ console.log(kleur.green("✓ rr.json is valid."));
735
+ console.log(` template: ${kleur.cyan(rr.template?.slug ?? "(none)")}`);
736
+ console.log(` features: ${kleur.cyan(rr.features?.length ?? 0)}`);
737
+ console.log(` slices: ${kleur.cyan(rr.slices?.length ?? 0)}`);
738
+ console.log(` skills: ${kleur.cyan(rr.skills?.length ?? 0)}`);
739
+
740
+ if (sliceCheck) doctorSlices(target, rr);
741
+ }
742
+
743
+ function doctorSlices(target, rr) {
744
+ console.log(kleur.bold(`\n→ Slice composition check\n`));
745
+ const installed = rr.slices ?? [];
746
+ if (installed.length === 0) {
747
+ console.log(kleur.dim(" (no slices installed — nothing to check)"));
513
748
  return;
514
749
  }
515
- console.log(kleur.red(`✖ rr.json has ${issues.length} issue(s):`));
516
- for (const i of issues) console.log(` · ${i}`);
517
- process.exit(1);
750
+
751
+ const errors = [];
752
+ const warnings = [];
753
+ const sliceMap = new Map((manifest.slices ?? []).map((s) => [s.slug, s]));
754
+ const present = new Set(installed.map((s) => s.slug));
755
+
756
+ for (const inst of installed) {
757
+ const def = sliceMap.get(inst.slug);
758
+ if (!def) {
759
+ warnings.push(`${inst.slug}: not in kitab manifest (custom slice — skipping peer check)`);
760
+ continue;
761
+ }
762
+
763
+ // 1. Slice path exists locally
764
+ const slicePath = path.join(target, def.slicePath);
765
+ if (!existsSync(slicePath)) {
766
+ errors.push(`${inst.slug}: missing on disk at ${def.slicePath}`);
767
+ }
768
+ const sliceJsonPath = path.join(slicePath, "slice.json");
769
+ if (existsSync(slicePath) && !existsSync(sliceJsonPath)) {
770
+ errors.push(`${inst.slug}: slice.json missing at ${def.slicePath}/slice.json`);
771
+ }
772
+
773
+ // 2. Convex paths exist locally (when declared)
774
+ for (const cp of def.convexPaths ?? []) {
775
+ const cpAbs = path.join(target, cp);
776
+ if (!existsSync(cpAbs)) {
777
+ warnings.push(`${inst.slug}: convex path missing at ${cp} (lift again with 'npx rr add ${inst.slug}')`);
778
+ }
779
+ }
780
+
781
+ // 3. Peers transitively present
782
+ for (const peer of def.peers ?? []) {
783
+ if (!present.has(peer.slug)) {
784
+ errors.push(`${inst.slug} requires peer ${peer.slug} ${peer.range} — not installed`);
785
+ }
786
+ }
787
+ }
788
+
789
+ // 4. Convex table-name collision across installed slices
790
+ const tableOwners = new Map();
791
+ for (const inst of installed) {
792
+ const def = sliceMap.get(inst.slug);
793
+ if (!def) continue;
794
+ for (const cp of def.convexPaths ?? []) {
795
+ const schemaFile = path.join(target, cp, "schema.ts");
796
+ if (!existsSync(schemaFile)) continue;
797
+ const body = readFileSync(schemaFile, "utf8");
798
+ const matches = [...body.matchAll(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*defineTable\(/gm)];
799
+ for (const m of matches) {
800
+ const tName = m[1];
801
+ if (tableOwners.has(tName)) {
802
+ errors.push(`convex table "${tName}" declared by both ${tableOwners.get(tName)} and ${inst.slug}`);
803
+ } else {
804
+ tableOwners.set(tName, inst.slug);
805
+ }
806
+ }
807
+ }
808
+ }
809
+
810
+ if (warnings.length > 0) {
811
+ console.log(kleur.yellow(` ⚠ ${warnings.length} warning(s):`));
812
+ for (const w of warnings) console.log(` · ${w}`);
813
+ }
814
+ if (errors.length > 0) {
815
+ console.log(kleur.red(`\n ✖ ${errors.length} error(s):`));
816
+ for (const e of errors) console.log(` · ${e}`);
817
+ process.exit(1);
818
+ }
819
+ if (errors.length === 0 && warnings.length === 0) {
820
+ console.log(kleur.green(` ✓ ${installed.length} slice(s) — composition healthy`));
821
+ }
518
822
  }
519
823
 
520
824
  function runMcpHint() {
@@ -536,6 +840,352 @@ Then in Claude Code: ${kleur.cyan("/mcp")} to see available rr_* tools.
536
840
  `);
537
841
  }
538
842
 
843
+ // ─── scaffold-slice ───────────────────────────────────────────────────────
844
+ //
845
+ // Creates a new slice from the kitab's `frontend/slices/_templates/example-feature/`
846
+ // reference. Pulls both the frontend half + the convex backend half, then
847
+ // rewrites the slug + camelCase identifiers everywhere they appear.
848
+
849
+ const VALID_CATEGORIES = ["ai", "auth", "data", "payment", "email", "realtime", "storage", "search", "content", "ui", "infra"];
850
+
851
+ async function runScaffoldSlice(rest) {
852
+ const { positional, flags } = parseFlags(rest);
853
+ const [slug] = positional;
854
+ if (!slug) {
855
+ throw new Error("Usage: rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]");
856
+ }
857
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) {
858
+ throw new Error(`Slug must be kebab-case (got "${slug}").`);
859
+ }
860
+ const category = typeof flags.category === "string" ? flags.category : "data";
861
+ if (!VALID_CATEGORIES.includes(category)) {
862
+ throw new Error(`--category must be one of: ${VALID_CATEGORIES.join(", ")}`);
863
+ }
864
+ const target = path.resolve(process.cwd(), typeof flags.target === "string" ? flags.target : ".");
865
+
866
+ const frontendDest = path.join(target, "frontend", "slices", slug);
867
+ const convexDest = path.join(target, "convex", "features", slug);
868
+ if (existsSync(frontendDest)) {
869
+ throw new Error(`Already exists: ${frontendDest}`);
870
+ }
871
+ if (existsSync(convexDest)) {
872
+ throw new Error(`Already exists: ${convexDest}`);
873
+ }
874
+
875
+ console.log(kleur.bold(`\n→ Scaffolding slice ${kleur.cyan(slug)} (${category})\n`));
876
+
877
+ process.stdout.write(` pulling frontend half ... `);
878
+ await pull("frontend/slices/_templates/example-feature", frontendDest);
879
+ console.log(kleur.green("ok"));
880
+
881
+ process.stdout.write(` pulling convex half ... `);
882
+ await pull("convex/features/example-feature", convexDest);
883
+ console.log(kleur.green("ok"));
884
+
885
+ process.stdout.write(` rewriting identifiers ... `);
886
+ rewriteSlugInTree(frontendDest, "example-feature", slug, category);
887
+ rewriteSlugInTree(convexDest, "example-feature", slug, category);
888
+ console.log(kleur.green("ok"));
889
+
890
+ console.log(`\n${kleur.green("✓")} Slice ${kleur.bold(slug)} scaffolded.`);
891
+ console.log(`\n${kleur.bold("Next:")}`);
892
+ console.log(` 1. Edit ${kleur.cyan(`frontend/slices/${slug}/slice.json`)} — set description, deps, peers.`);
893
+ console.log(` 2. Replace stub UI in ${kleur.cyan(`frontend/slices/${slug}/{page,components}`)}.`);
894
+ console.log(` 3. Define your backend in ${kleur.cyan(`convex/features/${slug}/{schema,queries,mutations}.ts`)}.`);
895
+ console.log(` 4. Spread tables in ${kleur.cyan(`convex/schema.ts`)}: ${kleur.dim(`...${camelCase(slug)}Tables`)}`);
896
+ console.log(` 5. Run ${kleur.cyan("npm run gen:slices && node packages/cli/scripts/validate-slice.mjs")}.\n`);
897
+ }
898
+
899
+ function camelCase(slug) {
900
+ return slug.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
901
+ }
902
+
903
+ function pascalCase(slug) {
904
+ const cc = camelCase(slug);
905
+ return cc.charAt(0).toUpperCase() + cc.slice(1);
906
+ }
907
+
908
+ function rewriteSlugInTree(dir, fromSlug, toSlug, newCategory) {
909
+ const fromCamel = camelCase(fromSlug);
910
+ const fromPascal = pascalCase(fromSlug);
911
+ const toCamel = camelCase(toSlug);
912
+ const toPascal = pascalCase(toSlug);
913
+
914
+ for (const entry of readdirSync(dir)) {
915
+ const full = path.join(dir, entry);
916
+ const stat = statSync(full);
917
+ if (stat.isDirectory()) {
918
+ rewriteSlugInTree(full, fromSlug, toSlug, newCategory);
919
+ continue;
920
+ }
921
+ let body = readFileSync(full, "utf8");
922
+ const before = body;
923
+ body = body
924
+ .replaceAll(fromSlug, toSlug)
925
+ .replaceAll(fromPascal, toPascal)
926
+ .replaceAll(fromCamel, toCamel);
927
+ // slice.json: also patch category if user passed one
928
+ if (entry === "slice.json" && newCategory) {
929
+ body = body.replace(/"category"\s*:\s*"[^"]*"/, `"category": "${newCategory}"`);
930
+ }
931
+ if (body !== before) writeFileSync(full, body);
932
+ }
933
+ }
934
+
935
+ // ─── lift ─────────────────────────────────────────────────────────────────
936
+ //
937
+ // `npx rr lift <source>:<path>` pulls a slice tree (and its convex pair, when
938
+ // known) from one of three source kinds:
939
+ //
940
+ // rahman:<slug> — kitab manifest entry (uses `slices.ts`)
941
+ // superspace:<sub-path> — local ~/projects/superspace/<sub-path>
942
+ // github:<owner>/<repo>/<sub-path> — arbitrary tiged pull
943
+ //
944
+ // Flags:
945
+ // --target <dir> — destination project root (default cwd)
946
+ // --dry-run — show what would be pulled, write nothing
947
+
948
+ async function runLift(rest) {
949
+ const { positional, flags } = parseFlags(rest);
950
+ const [src] = positional;
951
+ if (!src) {
952
+ throw new Error("Usage: rahman-resources lift <source>:<path> [--target <dir>] [--dry-run]");
953
+ }
954
+ const target = path.resolve(process.cwd(), typeof flags.target === "string" ? flags.target : ".");
955
+ const dryRun = !!flags["dry-run"];
956
+
957
+ const parsed = parseLiftSource(src);
958
+ console.log(kleur.bold(`\n→ Lift ${kleur.cyan(src)} ${dryRun ? kleur.yellow("(dry-run)") : ""}\n`));
959
+
960
+ const plan = await resolveLiftPlan(parsed, target);
961
+
962
+ for (const step of plan.steps) {
963
+ console.log(` ${kleur.dim(step.from)} → ${kleur.cyan(step.toRel)}`);
964
+ }
965
+ if (plan.peers.length > 0) {
966
+ console.log(`\n Peers required:`);
967
+ for (const p of plan.peers) console.log(` · ${kleur.cyan(p.slug)} ${kleur.dim(p.range)}`);
968
+ }
969
+ if (plan.npm.length > 0) console.log(`\n npm: ${plan.npm.join(" ")}`);
970
+ if (plan.shadcn.length > 0) console.log(` shadcn: ${plan.shadcn.join(" ")}`);
971
+ if (plan.env.length > 0) {
972
+ console.log(`\n env vars to set:`);
973
+ for (const e of plan.env) console.log(` ${e.scope === "next-public" ? "NEXT_PUBLIC_" : ""}${e.name}=… ${kleur.dim(`(${e.scope})`)}`);
974
+ }
975
+
976
+ if (dryRun) {
977
+ console.log(`\n${kleur.yellow("dry-run — no changes written.")}\n`);
978
+ return;
979
+ }
980
+
981
+ for (const step of plan.steps) {
982
+ process.stdout.write(`\n pulling ${kleur.dim(step.from)} ... `);
983
+ if (parsed.kind === "superspace-local") {
984
+ copyLocalTree(step.localFromAbs, step.toAbs);
985
+ } else {
986
+ await pull(step.from, step.toAbs);
987
+ }
988
+ console.log(kleur.green("ok"));
989
+ }
990
+
991
+ if (plan.npm.length > 0 && hasPackageJson(target)) {
992
+ const pm = detectPM(target);
993
+ console.log(kleur.bold(`\n→ Installing ${plan.npm.length} npm dep(s) via ${kleur.cyan(pm)}\n`));
994
+ await runPM(pm, plan.npm, target);
995
+ }
996
+ if (plan.shadcn.length > 0 && hasPackageJson(target)) {
997
+ await maybeRunShadcnAdd({ slug: parsed.slug ?? "lifted", shadcnComponents: plan.shadcn }, target, false);
998
+ }
999
+
1000
+ // Register the slice in the consumer's rr.json (if present).
1001
+ if (parsed.kind === "rahman" && rrExists(target)) {
1002
+ const slice = (manifest.slices ?? []).find((s) => s.slug === parsed.slug);
1003
+ if (slice) {
1004
+ const rr = readRr(target);
1005
+ rrAddSlice(rr, parsed.slug, { version: slice.version, category: slice.category });
1006
+ writeRr(rr, target);
1007
+ console.log(kleur.dim(` rr.json: slices += ${parsed.slug}@${slice.version}`));
1008
+ }
1009
+ }
1010
+
1011
+ console.log(`\n${kleur.green("✓")} Lift complete.`);
1012
+ if (plan.env.length > 0) {
1013
+ console.log(`${kleur.bold("Don't forget:")} set the env vars listed above before running.\n`);
1014
+ }
1015
+ }
1016
+
1017
+ function parseLiftSource(src) {
1018
+ const m = src.match(/^(rahman|superspace|github):(.+)$/);
1019
+ if (!m) {
1020
+ throw new Error(`lift: source must look like "rahman:<slug>" or "superspace:<path>" or "github:<owner>/<repo>/<path>"`);
1021
+ }
1022
+ const [, scheme, rest] = m;
1023
+ if (scheme === "rahman") {
1024
+ return { kind: "rahman", slug: rest };
1025
+ }
1026
+ if (scheme === "superspace") {
1027
+ return { kind: "superspace-local", subPath: rest };
1028
+ }
1029
+ // github:<owner>/<repo>/<sub-path>
1030
+ const gh = rest.match(/^([^/]+)\/([^/]+)\/(.+)$/);
1031
+ if (!gh) throw new Error(`lift: github source must be "<owner>/<repo>/<sub-path>"`);
1032
+ return { kind: "github", owner: gh[1], repo: gh[2], subPath: gh[3] };
1033
+ }
1034
+
1035
+ async function resolveLiftPlan(parsed, target) {
1036
+ const steps = [];
1037
+ const peers = [];
1038
+ const npm = [];
1039
+ const shadcn = [];
1040
+ const env = [];
1041
+
1042
+ if (parsed.kind === "rahman") {
1043
+ const slice = (manifest.slices ?? []).find((s) => s.slug === parsed.slug);
1044
+ if (!slice) {
1045
+ throw new Error(`Slice not found in manifest: ${parsed.slug}. Run 'list slices'.`);
1046
+ }
1047
+ steps.push({
1048
+ from: slice.slicePath,
1049
+ toRel: slice.slicePath,
1050
+ toAbs: path.join(target, slice.slicePath),
1051
+ });
1052
+ for (const cp of slice.convexPaths ?? []) {
1053
+ steps.push({ from: cp, toRel: cp, toAbs: path.join(target, cp) });
1054
+ }
1055
+ npm.push(...(slice.npm ?? []));
1056
+ shadcn.push(...(slice.shadcn ?? []));
1057
+ env.push(...(slice.env ?? []));
1058
+ peers.push(...(slice.peers ?? []));
1059
+ } else if (parsed.kind === "superspace-local") {
1060
+ const SUPERSPACE = process.env.RAHMAN_SUPERSPACE_PATH ?? path.join(process.env.HOME ?? "", "projects/superspace");
1061
+ const localFromAbs = path.join(SUPERSPACE, parsed.subPath);
1062
+ if (!existsSync(localFromAbs)) {
1063
+ throw new Error(`superspace local source not found: ${localFromAbs}\n set RAHMAN_SUPERSPACE_PATH to override.`);
1064
+ }
1065
+ steps.push({
1066
+ from: `~/projects/superspace/${parsed.subPath}`,
1067
+ toRel: parsed.subPath,
1068
+ toAbs: path.join(target, parsed.subPath),
1069
+ localFromAbs,
1070
+ });
1071
+ } else if (parsed.kind === "github") {
1072
+ steps.push({
1073
+ from: `${parsed.owner}/${parsed.repo}/${parsed.subPath}`,
1074
+ toRel: parsed.subPath,
1075
+ toAbs: path.join(target, parsed.subPath),
1076
+ });
1077
+ }
1078
+ return { steps, peers, npm, shadcn, env };
1079
+ }
1080
+
1081
+ function copyLocalTree(srcDir, destDir) {
1082
+ const stat = statSync(srcDir);
1083
+ if (!stat.isDirectory()) {
1084
+ mkdirSync(path.dirname(destDir), { recursive: true });
1085
+ writeFileSync(destDir, readFileSync(srcDir));
1086
+ return;
1087
+ }
1088
+ mkdirSync(destDir, { recursive: true });
1089
+ for (const name of readdirSync(srcDir)) {
1090
+ if (name === "node_modules" || name === ".next" || name === "dist") continue;
1091
+ copyLocalTree(path.join(srcDir, name), path.join(destDir, name));
1092
+ }
1093
+ }
1094
+
1095
+ // ─── publish-slice ────────────────────────────────────────────────────────
1096
+ //
1097
+ // Validate a local slice and (optionally) open a PR upstream to add it to
1098
+ // the kitab. Default dry-run; pass --open-pr to actually create the PR.
1099
+
1100
+ async function runPublishSlice(rest) {
1101
+ const { positional, flags } = parseFlags(rest);
1102
+ const [sliceDir] = positional;
1103
+ if (!sliceDir) {
1104
+ throw new Error("Usage: rahman-resources publish-slice <local-slice-dir> [--open-pr]");
1105
+ }
1106
+ const abs = path.resolve(process.cwd(), sliceDir);
1107
+ if (!existsSync(abs) || !existsSync(path.join(abs, "slice.json"))) {
1108
+ throw new Error(`Not a slice dir (no slice.json): ${abs}`);
1109
+ }
1110
+ console.log(kleur.bold(`\n→ Validating ${kleur.cyan(abs)}\n`));
1111
+
1112
+ // Run the validator script as a subprocess so any user-side override holds.
1113
+ await new Promise((resolve, reject) => {
1114
+ const ps = spawn(
1115
+ "node",
1116
+ [path.join(__dirname, "../scripts/validate-slice.mjs"), path.join(abs, "slice.json")],
1117
+ { stdio: "inherit", shell: true },
1118
+ );
1119
+ ps.on("error", reject);
1120
+ ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`validate-slice exited ${code}`))));
1121
+ });
1122
+
1123
+ // Read the slice's metadata so we can prefill the PR title + body.
1124
+ const sliceMeta = JSON.parse(readFileSync(path.join(abs, "slice.json"), "utf8"));
1125
+
1126
+ if (!flags["open-pr"]) {
1127
+ console.log(`\n${kleur.yellow("dry-run — pass --open-pr to scaffold the PR command.")}`);
1128
+ return;
1129
+ }
1130
+
1131
+ const ghAvailable = await checkGhInstalled();
1132
+
1133
+ if (!ghAvailable) {
1134
+ console.log(`\n${kleur.yellow("⚠ `gh` CLI not found.")}`);
1135
+ console.log(`Install it: ${kleur.cyan("https://cli.github.com")} then re-run with --open-pr.`);
1136
+ console.log(`Manual fallback steps:`);
1137
+ console.log(`
1138
+ 1. Fork ${kleur.cyan("https://github.com/rahmanef63/resource-site")} on GitHub.
1139
+ 2. Clone your fork: ${kleur.cyan("git clone https://github.com/<you>/resource-site && cd resource-site")}
1140
+ 3. Copy slice: ${kleur.cyan(`cp -r ${abs} frontend/slices/${sliceMeta.slug}`)}
1141
+ 4. Copy convex half: ${kleur.cyan(`cp -r ${abs.replace("frontend/slices", "convex/features")} convex/features/${sliceMeta.slug}`)} (if exists)
1142
+ 5. Add entry to ${kleur.cyan("lib/content/slices.ts")}
1143
+ 6. ${kleur.cyan("npm run slices:check")}
1144
+ 7. ${kleur.cyan(`git checkout -b feat/slice-${sliceMeta.slug} && git add -A && git commit -m "feat(slices): add ${sliceMeta.slug}" && git push -u origin feat/slice-${sliceMeta.slug}`)}
1145
+ 8. Open PR via GitHub UI.
1146
+ `);
1147
+ return;
1148
+ }
1149
+
1150
+ // gh installed — emit ready-to-run gh commands.
1151
+ const branchName = `feat/slice-${sliceMeta.slug}`;
1152
+ const title = `feat(slices): add ${sliceMeta.slug}`;
1153
+ const body = [
1154
+ `Adds the **${sliceMeta.title}** slice (${sliceMeta.category}, v${sliceMeta.version}).`,
1155
+ "",
1156
+ sliceMeta.description,
1157
+ "",
1158
+ "Validated locally with `npm run slices:check`.",
1159
+ "",
1160
+ "🤖 Generated with [`rahman-resources publish-slice`](https://github.com/rahmanef63/resource-site)",
1161
+ ].join("\n");
1162
+
1163
+ console.log(`\n${kleur.bold("✓")} ${kleur.green("gh CLI detected.")} Run these from inside YOUR fork of the kitab repo:`);
1164
+ console.log(`
1165
+ # Inside your forked clone of rahmanef63/resource-site, copy the slice in:
1166
+ ${kleur.cyan(`cp -r ${abs} frontend/slices/${sliceMeta.slug}`)}
1167
+ ${kleur.cyan(`# Add ${sliceMeta.slug} entry to lib/content/slices.ts`)}
1168
+ ${kleur.cyan(`npm run slices:check`)}
1169
+
1170
+ # Then open the PR (gh handles fork + push):
1171
+ ${kleur.cyan(`git checkout -b ${branchName}`)}
1172
+ ${kleur.cyan(`git add -A && git commit -m "${title}"`)}
1173
+ ${kleur.cyan(`gh pr create --repo rahmanef63/resource-site \\
1174
+ --title "${title}" \\
1175
+ --body ${JSON.stringify(body)}`)}
1176
+
1177
+ ${kleur.dim("(If you haven't forked yet, run `gh repo fork rahmanef63/resource-site --clone` first.)")}
1178
+ `);
1179
+ }
1180
+
1181
+ async function checkGhInstalled() {
1182
+ return new Promise((resolve) => {
1183
+ const ps = spawn("gh", ["--version"], { stdio: "ignore", shell: true });
1184
+ ps.on("error", () => resolve(false));
1185
+ ps.on("exit", (code) => resolve(code === 0));
1186
+ });
1187
+ }
1188
+
539
1189
  // ─── helpers ──────────────────────────────────────────────────────────────
540
1190
 
541
1191
  async function pull(repoPath, dest) {